@rm0nroe/coach-claw 1.0.6

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. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/coach/README.md +99 -0
  4. package/coach/bin/aggregate_facets.py +274 -0
  5. package/coach/bin/analyze.py +678 -0
  6. package/coach/bin/bank.py +247 -0
  7. package/coach/bin/banner_themes.py +645 -0
  8. package/coach/bin/coach_paths.py +33 -0
  9. package/coach/bin/coexistence_check.py +129 -0
  10. package/coach/bin/configure.py +245 -0
  11. package/coach/bin/cron_check.py +81 -0
  12. package/coach/bin/default_statusline.py +135 -0
  13. package/coach/bin/doctor.py +663 -0
  14. package/coach/bin/insights-llm.sh +264 -0
  15. package/coach/bin/insights.sh +163 -0
  16. package/coach/bin/insights_window.py +111 -0
  17. package/coach/bin/marker_io.py +154 -0
  18. package/coach/bin/merge.py +671 -0
  19. package/coach/bin/redact.py +86 -0
  20. package/coach/bin/render_env.py +148 -0
  21. package/coach/bin/reward_hints.py +87 -0
  22. package/coach/bin/run-insights.sh +20 -0
  23. package/coach/bin/run_with_lock.py +85 -0
  24. package/coach/bin/scoring.py +260 -0
  25. package/coach/bin/skill_inventory.py +215 -0
  26. package/coach/bin/stats.py +459 -0
  27. package/coach/bin/status.py +293 -0
  28. package/coach/bin/statusline_self_patch.py +205 -0
  29. package/coach/bin/statusline_variants.py +146 -0
  30. package/coach/bin/statusline_wrap.py +244 -0
  31. package/coach/bin/statusline_wrap_action.py +460 -0
  32. package/coach/bin/switch_to_plugin.py +256 -0
  33. package/coach/bin/themes.py +256 -0
  34. package/coach/bin/user_config.py +176 -0
  35. package/coach/bin/xp_accounting.py +98 -0
  36. package/coach/changelog.md +4 -0
  37. package/coach/default-statusline-command.sh +19 -0
  38. package/coach/default-statusline-wrap-command.sh +15 -0
  39. package/coach/profile.yaml +37 -0
  40. package/coach/tests/conftest.py +13 -0
  41. package/coach/tests/test_aggregate_facets.py +379 -0
  42. package/coach/tests/test_analyze_aggregate.py +153 -0
  43. package/coach/tests/test_analyze_redaction.py +105 -0
  44. package/coach/tests/test_analyze_strengths.py +165 -0
  45. package/coach/tests/test_bank_atomic_write.py +61 -0
  46. package/coach/tests/test_bank_concurrency.py +126 -0
  47. package/coach/tests/test_banner_themes.py +981 -0
  48. package/coach/tests/test_celebrate_dedup.py +409 -0
  49. package/coach/tests/test_coach_paths.py +50 -0
  50. package/coach/tests/test_coexistence_check.py +128 -0
  51. package/coach/tests/test_configure.py +258 -0
  52. package/coach/tests/test_cron_check.py +118 -0
  53. package/coach/tests/test_cron_nudge_hook.py +134 -0
  54. package/coach/tests/test_detection_parity.py +105 -0
  55. package/coach/tests/test_doctor.py +595 -0
  56. package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
  57. package/coach/tests/test_hook_module_resolution.py +116 -0
  58. package/coach/tests/test_hook_relevance.py +996 -0
  59. package/coach/tests/test_hook_render_env.py +364 -0
  60. package/coach/tests/test_hook_session_id_guard.py +160 -0
  61. package/coach/tests/test_insights_llm.py +759 -0
  62. package/coach/tests/test_insights_llm_venv_path.py +109 -0
  63. package/coach/tests/test_insights_window.py +237 -0
  64. package/coach/tests/test_install.py +1150 -0
  65. package/coach/tests/test_install_pyyaml_fallback.py +142 -0
  66. package/coach/tests/test_marker_consumption.py +167 -0
  67. package/coach/tests/test_marker_writer_locking.py +305 -0
  68. package/coach/tests/test_merge.py +413 -0
  69. package/coach/tests/test_no_broken_mktemp.py +90 -0
  70. package/coach/tests/test_render_env.py +137 -0
  71. package/coach/tests/test_render_env_glyphs.py +119 -0
  72. package/coach/tests/test_reward_hints.py +59 -0
  73. package/coach/tests/test_scoring.py +147 -0
  74. package/coach/tests/test_session_start_weekly_trigger.py +92 -0
  75. package/coach/tests/test_skill_inventory.py +368 -0
  76. package/coach/tests/test_stats_hybrid.py +142 -0
  77. package/coach/tests/test_status_accounting.py +41 -0
  78. package/coach/tests/test_statusline_failsafe.py +70 -0
  79. package/coach/tests/test_statusline_self_patch.py +261 -0
  80. package/coach/tests/test_statusline_variants.py +110 -0
  81. package/coach/tests/test_statusline_wrap.py +196 -0
  82. package/coach/tests/test_statusline_wrap_action.py +408 -0
  83. package/coach/tests/test_switch_to_plugin.py +360 -0
  84. package/coach/tests/test_themes.py +104 -0
  85. package/coach/tests/test_user_config.py +160 -0
  86. package/coach/tests/test_wrap_announce_hook.py +130 -0
  87. package/coach/tests/test_xp_accounting.py +55 -0
  88. package/hooks/coach-session-start.py +536 -0
  89. package/hooks/coach-user-prompt.py +2288 -0
  90. package/install-launchd.sh +102 -0
  91. package/install.sh +597 -0
  92. package/launchd/com.local.claude-coach.plist.template +34 -0
  93. package/launchd/run-insights.sh +20 -0
  94. package/npm/coach-claw.js +259 -0
  95. package/package.json +52 -0
  96. package/requirements.txt +11 -0
  97. package/settings-snippet.json +31 -0
  98. package/skills/coach/SKILL.md +107 -0
  99. package/skills/coach-insights/SKILL.md +78 -0
  100. package/skills/config/SKILL.md +149 -0
@@ -0,0 +1,409 @@
1
+ """coach-user-prompt.py: _assemble_celebrate_block deduplicates queued
2
+ markers and surfaces a catch-up framing line when banners predate today.
3
+
4
+ Pinned by a real bug: queued /coach-insights events accumulate in the
5
+ .pending_streak_rewards / .pending_graduation marker files. Without
6
+ dedup, two ticks for the same pattern (2/5 + 3/5) both rendered, and a
7
+ graduation didn't suppress its same-batch tick. Without catch-up
8
+ framing, queued events looked like they came from the user's first
9
+ command in the session.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+
20
+ @pytest.fixture(scope="module")
21
+ def cup():
22
+ repo_path = Path(__file__).resolve().parents[2] / "hooks" / "coach-user-prompt.py"
23
+ path = repo_path if repo_path.exists() else Path.home() / ".claude" / "hooks" / "coach-user-prompt.py"
24
+ if not path.exists():
25
+ pytest.skip(f"hook not installed at {path}")
26
+ spec = importlib.util.spec_from_file_location("cup_under_test_celebrate", str(path))
27
+ mod = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(mod)
29
+ return mod
30
+
31
+
32
+ @pytest.fixture
33
+ def now():
34
+ return datetime(2026, 5, 6, 12, 0, tzinfo=timezone.utc)
35
+
36
+
37
+ # -----------------------------------------------------------------------------
38
+ # Per-pattern dedup: highest streak wins
39
+ # -----------------------------------------------------------------------------
40
+
41
+ def test_dedup_keeps_highest_streak_per_pattern(cup, now):
42
+ """Two ticks for the same pattern (e.g. yesterday's 2/5 + today's 3/5
43
+ accumulated unconsumed) collapse to ONE banner showing the higher
44
+ streak — the lower one is subsumed."""
45
+ block = cup._assemble_celebrate_block(
46
+ grads=[],
47
+ regs=[],
48
+ streak_rewards=[
49
+ {"id": "effective-skill-use", "name": "effective skill use",
50
+ "direction": "positive", "streak": 2, "target": 5, "xp_awarded": 1},
51
+ {"id": "effective-skill-use", "name": "effective skill use",
52
+ "direction": "positive", "streak": 3, "target": 5, "xp_awarded": 1},
53
+ ],
54
+ levelup=None,
55
+ caught_up=False,
56
+ env="terminal",
57
+ )
58
+ assert block is not None
59
+ # Exactly ONE positive-direction streak banner, with the 3/5 streak.
60
+ assert block.count("> ↑ ") == 1
61
+ assert "3/5" in block
62
+ assert "2/5" not in block
63
+
64
+
65
+ def test_dedup_handles_missing_id_gracefully(cup, now):
66
+ """Marker entries without an id are dropped (defensive: malformed
67
+ legacy markers shouldn't crash the pipeline)."""
68
+ block = cup._assemble_celebrate_block(
69
+ grads=[],
70
+ regs=[],
71
+ streak_rewards=[
72
+ {"id": "", "name": "broken", "direction": "negative", "streak": 1,
73
+ "target": 5, "xp_awarded": 1},
74
+ {"id": "valid-pattern", "name": "valid pattern",
75
+ "direction": "negative", "streak": 2, "target": 5, "xp_awarded": 1},
76
+ ],
77
+ levelup=None,
78
+ caught_up=False,
79
+ env="terminal",
80
+ )
81
+ assert block is not None
82
+ # Only the valid one renders.
83
+ assert block.count("> ↓ ") == 1
84
+ assert "valid pattern" in block
85
+
86
+
87
+ # -----------------------------------------------------------------------------
88
+ # Graduation suppresses same-batch tick
89
+ # -----------------------------------------------------------------------------
90
+
91
+ def test_graduation_suppresses_same_batch_tick(cup, now):
92
+ """If a pattern graduated and also has a queued tick for the same
93
+ batch, the graduation banner renders alone — no redundant tick."""
94
+ block = cup._assemble_celebrate_block(
95
+ grads=[
96
+ {"id": "safe-git-hygiene", "name": "safe git hygiene",
97
+ "direction": "positive", "graduated_reason": "present-5-runs"},
98
+ ],
99
+ regs=[],
100
+ streak_rewards=[
101
+ # The 4/5 tick that was queued from the prior insights run.
102
+ {"id": "safe-git-hygiene", "name": "safe git hygiene",
103
+ "direction": "positive", "streak": 4, "target": 5, "xp_awarded": 2},
104
+ ],
105
+ levelup=None,
106
+ caught_up=False,
107
+ env="terminal",
108
+ )
109
+ assert block is not None
110
+ # Mastery banner present; tick banner absent.
111
+ assert "🎓🌟 **MASTERED: safe git hygiene**" in block
112
+ assert "> ↑ " not in block
113
+ assert "4/5" not in block
114
+
115
+
116
+ def test_graduation_doesnt_suppress_other_patterns_ticks(cup, now):
117
+ """Suppression is per-id — graduations don't kill ticks for
118
+ unrelated patterns in the same batch."""
119
+ block = cup._assemble_celebrate_block(
120
+ grads=[
121
+ {"id": "safe-git-hygiene", "name": "safe git hygiene",
122
+ "direction": "positive", "graduated_reason": "present-5-runs"},
123
+ ],
124
+ regs=[],
125
+ streak_rewards=[
126
+ {"id": "safe-git-hygiene", "name": "safe git hygiene",
127
+ "direction": "positive", "streak": 4, "target": 5, "xp_awarded": 2},
128
+ {"id": "effective-skill-use", "name": "effective skill use",
129
+ "direction": "positive", "streak": 3, "target": 5, "xp_awarded": 1},
130
+ ],
131
+ levelup=None,
132
+ caught_up=False,
133
+ env="terminal",
134
+ )
135
+ assert block is not None
136
+ assert "🎓🌟 **MASTERED: safe git hygiene**" in block
137
+ # safe-git-hygiene tick gone, effective-skill-use tick survives.
138
+ assert block.count("> ↑ ") == 1
139
+ assert "effective skill use" in block
140
+
141
+
142
+ # -----------------------------------------------------------------------------
143
+ # Catch-up framing
144
+ # -----------------------------------------------------------------------------
145
+
146
+ CATCHUP_LINE = "Milestones earned across earlier sessions"
147
+
148
+
149
+ def test_catchup_prefix_when_caught_up_true(cup, now):
150
+ """When caught_up=True, the catch-up framing line appears between
151
+ the verbatim-render instruction and the banners."""
152
+ block = cup._assemble_celebrate_block(
153
+ grads=[],
154
+ regs=[],
155
+ streak_rewards=[
156
+ {"id": "p1", "name": "pattern one", "direction": "negative",
157
+ "streak": 1, "target": 5, "xp_awarded": 1},
158
+ ],
159
+ levelup=None,
160
+ caught_up=True,
161
+ env="terminal",
162
+ )
163
+ assert block is not None
164
+ assert CATCHUP_LINE in block
165
+
166
+
167
+ def test_catchup_prefix_absent_when_caught_up_false(cup, now):
168
+ """When caught_up=False, no catch-up line — banners look like fresh
169
+ same-session events."""
170
+ block = cup._assemble_celebrate_block(
171
+ grads=[],
172
+ regs=[],
173
+ streak_rewards=[
174
+ {"id": "p1", "name": "pattern one", "direction": "negative",
175
+ "streak": 1, "target": 5, "xp_awarded": 1},
176
+ ],
177
+ levelup=None,
178
+ caught_up=False,
179
+ env="terminal",
180
+ )
181
+ assert block is not None
182
+ assert CATCHUP_LINE not in block
183
+
184
+
185
+ # -----------------------------------------------------------------------------
186
+ # _marker_predates_today: drives `caught_up`
187
+ # -----------------------------------------------------------------------------
188
+
189
+ def test_marker_predates_today_returns_true_for_yesterday(cup, now):
190
+ payload = {"created_at": (now - timedelta(days=2)).isoformat()}
191
+ assert cup._marker_predates_today(payload, now) is True
192
+
193
+
194
+ def test_marker_predates_today_returns_false_for_same_day(cup, now):
195
+ payload = {"created_at": now.isoformat()}
196
+ assert cup._marker_predates_today(payload, now) is False
197
+
198
+
199
+ def test_marker_predates_today_handles_missing_data(cup, now):
200
+ # No payload, empty payload, no created_at — all safe.
201
+ assert cup._marker_predates_today(None, now) is False
202
+ assert cup._marker_predates_today({}, now) is False
203
+ assert cup._marker_predates_today({"created_at": None}, now) is False
204
+ assert cup._marker_predates_today({"created_at": "garbage"}, now) is False
205
+
206
+
207
+ def test_marker_predates_today_prefers_oldest_entry_at(cup, now):
208
+ """When both fields are present, oldest_entry_at wins. This is the
209
+ real-world post-fix shape: today's append updates created_at to now
210
+ but oldest_entry_at still anchors at the prior write.
211
+
212
+ Pre-v0.4.2 this test would have failed (catch-up went silent for
213
+ carried-over entries because only created_at was inspected)."""
214
+ payload = {
215
+ "created_at": now.isoformat(), # today's append
216
+ "oldest_entry_at": (now - timedelta(days=1)).isoformat(), # prior write
217
+ }
218
+ assert cup._marker_predates_today(payload, now) is True
219
+
220
+
221
+ def test_marker_predates_today_falls_back_to_created_at_for_legacy(cup, now):
222
+ """Markers written before v0.4.2 don't have oldest_entry_at. The
223
+ catch-up predicate must still work for them via created_at fallback."""
224
+ payload = {"created_at": (now - timedelta(days=2)).isoformat()}
225
+ # No oldest_entry_at field — should still detect the predates-today case.
226
+ assert cup._marker_predates_today(payload, now) is True
227
+
228
+
229
+ # -----------------------------------------------------------------------------
230
+ # atomic_marker_rmw_append: oldest_entry_at preservation across appends
231
+ # -----------------------------------------------------------------------------
232
+
233
+ @pytest.fixture
234
+ def marker_io_mod():
235
+ """Load coach/bin/marker_io.py for direct producer-side testing."""
236
+ path = Path(__file__).resolve().parents[1] / "bin" / "marker_io.py"
237
+ if not path.exists():
238
+ pytest.skip(f"marker_io.py not found at {path}")
239
+ spec = importlib.util.spec_from_file_location("marker_io_under_test", str(path))
240
+ mod = importlib.util.module_from_spec(spec)
241
+ spec.loader.exec_module(mod)
242
+ return mod
243
+
244
+
245
+ def test_carried_over_append_preserves_oldest_entry_at(cup, marker_io_mod, tmp_path, now):
246
+ """The teammate-reported P2 bug, end-to-end:
247
+
248
+ Yesterday's /coach-insights writes streak markers; user doesn't
249
+ consume them; today's /coach-insights appends fresh markers via
250
+ atomic_marker_rmw_append. Without the fix, the marker's top-level
251
+ created_at gets reset to today, and _marker_predates_today returns
252
+ False — silently dropping catch-up framing for the carried-over
253
+ entries from yesterday.
254
+
255
+ With the fix, oldest_entry_at preserves yesterday's timestamp
256
+ across the append, so catch-up correctly fires."""
257
+ import json
258
+ yesterday = now - timedelta(days=1)
259
+ path = tmp_path / ".pending_streak_rewards"
260
+
261
+ # Day N-1: yesterday's insights run leaves a marker with one entry.
262
+ marker_io_mod.atomic_marker_rmw_append(
263
+ path, "rewards",
264
+ [{"id": "old-pattern", "name": "old pattern", "direction": "negative",
265
+ "streak": 1, "target": 5, "xp_awarded": 1}],
266
+ yesterday,
267
+ )
268
+
269
+ # Day N: today's insights run appends a fresh entry to the same marker.
270
+ marker_io_mod.atomic_marker_rmw_append(
271
+ path, "rewards",
272
+ [{"id": "new-pattern", "name": "new pattern", "direction": "negative",
273
+ "streak": 2, "target": 5, "xp_awarded": 1}],
274
+ now,
275
+ )
276
+
277
+ payload = json.loads(path.read_text())
278
+
279
+ # Both entries present.
280
+ assert [r["id"] for r in payload["rewards"]] == ["old-pattern", "new-pattern"]
281
+
282
+ # Top-level created_at advanced to today's write (drives TTL).
283
+ assert payload["created_at"] == now.isoformat()
284
+
285
+ # oldest_entry_at preserved from yesterday's write (drives catch-up).
286
+ assert payload["oldest_entry_at"] == yesterday.isoformat()
287
+
288
+ # And the predicate the consumer uses returns True.
289
+ assert cup._marker_predates_today(payload, now) is True
290
+
291
+
292
+ def test_first_append_anchors_oldest_entry_at_at_now(marker_io_mod, tmp_path, now):
293
+ """A fresh marker (no prior file) anchors oldest_entry_at at `now`,
294
+ so a same-day append doesn't spuriously trigger catch-up framing."""
295
+ import json
296
+ path = tmp_path / ".pending_streak_rewards"
297
+
298
+ marker_io_mod.atomic_marker_rmw_append(
299
+ path, "rewards",
300
+ [{"id": "p1", "name": "pattern one", "direction": "negative",
301
+ "streak": 1, "target": 5, "xp_awarded": 1}],
302
+ now,
303
+ )
304
+
305
+ payload = json.loads(path.read_text())
306
+ assert payload["created_at"] == now.isoformat()
307
+ assert payload["oldest_entry_at"] == now.isoformat()
308
+
309
+
310
+ def test_append_against_legacy_marker_promotes_created_at(marker_io_mod, tmp_path, now):
311
+ """An existing marker written by pre-v0.4.2 marker_io has no
312
+ oldest_entry_at field. The first append after upgrade must promote
313
+ the existing created_at into oldest_entry_at so users on the
314
+ upgrade boundary still get catch-up framing for entries they
315
+ haven't consumed yet."""
316
+ import json
317
+ path = tmp_path / ".pending_streak_rewards"
318
+ yesterday = now - timedelta(days=1)
319
+
320
+ # Hand-write a legacy-shape marker (no oldest_entry_at).
321
+ legacy = {
322
+ "rewards": [{"id": "legacy", "name": "legacy", "direction": "negative",
323
+ "streak": 1, "target": 5, "xp_awarded": 1}],
324
+ "created_at": yesterday.isoformat(),
325
+ "consumed_by": [],
326
+ }
327
+ path.write_text(json.dumps(legacy))
328
+
329
+ # Today's append should promote yesterday's created_at into oldest_entry_at.
330
+ marker_io_mod.atomic_marker_rmw_append(
331
+ path, "rewards",
332
+ [{"id": "fresh", "name": "fresh", "direction": "negative",
333
+ "streak": 2, "target": 5, "xp_awarded": 1}],
334
+ now,
335
+ )
336
+
337
+ payload = json.loads(path.read_text())
338
+ assert payload["created_at"] == now.isoformat()
339
+ assert payload["oldest_entry_at"] == yesterday.isoformat()
340
+ assert [r["id"] for r in payload["rewards"]] == ["legacy", "fresh"]
341
+
342
+
343
+ # -----------------------------------------------------------------------------
344
+ # Empty case: returns None, not an empty <coach-celebrate> block
345
+ # -----------------------------------------------------------------------------
346
+
347
+ def test_assemble_returns_none_when_no_events(cup, now):
348
+ """No queued events → return None so the consumer skips emitting
349
+ a celebrate block entirely."""
350
+ assert cup._assemble_celebrate_block(
351
+ grads=[], regs=[], streak_rewards=[], levelup=None,
352
+ caught_up=False, env="terminal",
353
+ ) is None
354
+
355
+
356
+ # -----------------------------------------------------------------------------
357
+ # Verbatim-render contract: instruction header + closing tag
358
+ # -----------------------------------------------------------------------------
359
+
360
+ def test_celebrate_block_includes_verbatim_instruction(cup, now):
361
+ """The render-verbatim instruction must be present so Claude knows
362
+ not to re-interpret labels or substitute slugs for names."""
363
+ block = cup._assemble_celebrate_block(
364
+ grads=[],
365
+ regs=[],
366
+ streak_rewards=[{"id": "p1", "name": "pattern one",
367
+ "direction": "negative", "streak": 1, "target": 5,
368
+ "xp_awarded": 1}],
369
+ levelup=None,
370
+ caught_up=False,
371
+ env="terminal",
372
+ )
373
+ assert block is not None
374
+ assert block.startswith("<coach-celebrate>\n")
375
+ assert block.endswith("\n</coach-celebrate>")
376
+ assert "Render this block VERBATIM" in block
377
+ # The old "pick by direction" / "Rules for every banner" instruction
378
+ # footer must NOT come back — its presence was the bug surface.
379
+ assert "pick by direction" not in block
380
+ assert "Rules for every banner" not in block
381
+
382
+
383
+ def test_celebrate_combines_all_event_kinds(cup, now):
384
+ """Regression + streak + graduation + level-up — verify ordering and
385
+ that all four sections are present without bleeding into each other."""
386
+ block = cup._assemble_celebrate_block(
387
+ grads=[{"id": "g1", "name": "pattern g", "direction": "positive",
388
+ "graduated_reason": "present-5-runs"}],
389
+ regs=[{"id": "r1", "name": "pattern r",
390
+ "originally_graduated_at": "2026-04-01"}],
391
+ streak_rewards=[{"id": "s1", "name": "pattern s",
392
+ "direction": "negative", "streak": 2, "target": 5,
393
+ "xp_awarded": 1}],
394
+ levelup={"from": "L3 X", "to": "Y", "to_idx": 3, "xp_at_levelup": 100},
395
+ caught_up=False,
396
+ env="terminal",
397
+ )
398
+ assert block is not None
399
+ # All four banner heads present.
400
+ assert "**Regressed: pattern r**" in block
401
+ assert "> ↓ " in block
402
+ assert "**MASTERED: pattern g**" in block
403
+ assert "**Level up!**" in block
404
+ # Documented order: regressions, streaks, graduations, level-up.
405
+ pos_reg = block.index("Regressed:")
406
+ pos_streak = block.index("> ↓ ")
407
+ pos_grad = block.index("MASTERED:")
408
+ pos_levelup = block.index("Level up!")
409
+ assert pos_reg < pos_streak < pos_grad < pos_levelup
@@ -0,0 +1,50 @@
1
+ """coach_paths.py — single source of truth for ~/.claude/coach/ resolution."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import coach_paths
7
+
8
+
9
+ def test_resolve_default_path(monkeypatch):
10
+ """No COACH_CONFIG_DIR set → falls back to ~/.claude/coach."""
11
+ monkeypatch.delenv("COACH_CONFIG_DIR", raising=False)
12
+ assert coach_paths.resolve_coach_dir() == Path.home() / ".claude" / "coach"
13
+
14
+
15
+ def test_resolve_honors_env_override(tmp_path, monkeypatch):
16
+ """COACH_CONFIG_DIR overrides the default path."""
17
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(tmp_path))
18
+ assert coach_paths.resolve_coach_dir() == tmp_path
19
+
20
+
21
+ def test_resolve_per_call(tmp_path, monkeypatch):
22
+ """Resolution happens at every call, not cached at import time —
23
+ tests/wrappers can flip the env var mid-process."""
24
+ a = tmp_path / "a"
25
+ b = tmp_path / "b"
26
+ a.mkdir()
27
+ b.mkdir()
28
+
29
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(a))
30
+ assert coach_paths.resolve_coach_dir() == a
31
+
32
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(b))
33
+ assert coach_paths.resolve_coach_dir() == b
34
+
35
+
36
+ def test_resolve_empty_env_falls_back(monkeypatch):
37
+ """Empty string COACH_CONFIG_DIR is treated as unset (falsy guard)."""
38
+ monkeypatch.setenv("COACH_CONFIG_DIR", "")
39
+ assert coach_paths.resolve_coach_dir() == Path.home() / ".claude" / "coach"
40
+
41
+
42
+ def test_user_config_delegates_to_coach_paths(tmp_path, monkeypatch):
43
+ """user_config._resolve_config_path() should call into the shared
44
+ helper so env-var contract is enforced in one place. Verified
45
+ behaviorally: setting COACH_CONFIG_DIR redirects user_config writes
46
+ to the same dir that coach_paths reports."""
47
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(tmp_path))
48
+ import user_config
49
+ assert user_config._resolve_config_path() == tmp_path / ".user_config.json"
50
+ assert coach_paths.resolve_coach_dir() == tmp_path
@@ -0,0 +1,128 @@
1
+ """coexistence_check.py — detect when CLI hooks are registered so the
2
+ plugin can self-defer.
3
+
4
+ CLI distribution has Coach hooks at ~/.claude/hooks/coach-*.py registered
5
+ in settings.json. Plugin distribution has them at
6
+ ${CLAUDE_PLUGIN_ROOT}/hooks/coach-*.py registered via hooks.json. A
7
+ user with both installed must NOT get double-fires. The check returns
8
+ exit code 10 when CLI hooks are present so bootstrap.sh can defer.
9
+
10
+ Unit tests only — bootstrap.sh integration is in
11
+ tests/plugin/test_coexistence_integration.py.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+
17
+ import coexistence_check as cc
18
+
19
+
20
+ def _settings_with_cli_hooks() -> dict:
21
+ """Synthesize an install.sh-style settings.json — hooks point at
22
+ absolute paths under ~/.claude/hooks/, NOT under any plugin root."""
23
+ return {
24
+ "hooks": {
25
+ "SessionStart": [{"hooks": [{
26
+ "type": "command",
27
+ "command": "/usr/bin/python3 /Users/foo/.claude/hooks/coach-session-start.py",
28
+ }]}],
29
+ "UserPromptSubmit": [{"hooks": [{
30
+ "type": "command",
31
+ "command": "/usr/bin/python3 /Users/foo/.claude/hooks/coach-user-prompt.py",
32
+ }]}],
33
+ }
34
+ }
35
+
36
+
37
+ def _settings_with_plugin_hooks(plugin_root: str) -> dict:
38
+ """Plugin-style: hook commands include the plugin root path."""
39
+ cmd = f"{plugin_root}/bin/bootstrap.sh {plugin_root}/hooks/coach-session-start.py"
40
+ return {
41
+ "hooks": {
42
+ "SessionStart": [{"hooks": [{"type": "command", "command": cmd}]}],
43
+ }
44
+ }
45
+
46
+
47
+ def test_returns_0_when_settings_missing(tmp_path, monkeypatch):
48
+ monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(tmp_path / "no-such.json"))
49
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin"))
50
+ assert cc.main() == 0
51
+
52
+
53
+ def test_returns_0_when_no_hooks_block(tmp_path, monkeypatch):
54
+ settings = tmp_path / "settings.json"
55
+ settings.write_text(json.dumps({"permissions": {}}))
56
+ monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings))
57
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin"))
58
+ assert cc.main() == 0
59
+
60
+
61
+ def test_returns_0_when_only_plugin_hooks(tmp_path, monkeypatch):
62
+ """Plugin's own hooks present but no CLI hooks → no defer."""
63
+ plugin_root = str(tmp_path / "plugin")
64
+ settings = tmp_path / "settings.json"
65
+ settings.write_text(json.dumps(_settings_with_plugin_hooks(plugin_root)))
66
+ monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings))
67
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", plugin_root)
68
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(tmp_path / "coach"))
69
+ assert cc.main() == 0
70
+
71
+
72
+ def test_returns_10_when_cli_hooks_present(tmp_path, monkeypatch):
73
+ """CLI-style hook entries (no plugin root in command) → defer."""
74
+ plugin_root = str(tmp_path / "plugin")
75
+ settings = tmp_path / "settings.json"
76
+ settings.write_text(json.dumps(_settings_with_cli_hooks()))
77
+ monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings))
78
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", plugin_root)
79
+ coach_dir = tmp_path / "coach"
80
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
81
+ assert cc.main() == 10
82
+ # Defer marker written
83
+ marker = coach_dir / ".plugin-deferred"
84
+ assert marker.exists()
85
+ payload = json.loads(marker.read_text())
86
+ assert "deferred_at" in payload
87
+ assert payload["reason"] == "cli-hooks-detected"
88
+
89
+
90
+ def test_returns_0_for_unrelated_hooks(tmp_path, monkeypatch):
91
+ """A user has some OTHER tool's hooks registered. No coach pattern
92
+ in the commands → no defer."""
93
+ settings = tmp_path / "settings.json"
94
+ settings.write_text(json.dumps({
95
+ "hooks": {
96
+ "SessionStart": [{"hooks": [{
97
+ "type": "command",
98
+ "command": "echo 'some other tool fired'",
99
+ }]}],
100
+ }
101
+ }))
102
+ monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings))
103
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin"))
104
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(tmp_path / "coach"))
105
+ assert cc.main() == 0
106
+
107
+
108
+ def test_returns_0_on_malformed_settings(tmp_path, monkeypatch):
109
+ """Malformed JSON → fail-safe to 0 (no defer). Better to risk
110
+ double-fire than to silently disable the plugin on a parse error
111
+ that has nothing to do with us."""
112
+ settings = tmp_path / "settings.json"
113
+ settings.write_text("{ not valid json")
114
+ monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings))
115
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin"))
116
+ assert cc.main() == 0
117
+
118
+
119
+ def test_returns_10_when_plugin_root_unset_but_cli_hooks_present(tmp_path, monkeypatch):
120
+ """If CLAUDE_PLUGIN_ROOT is somehow unset (shouldn't happen at
121
+ runtime but be defensive), any coach hook command in settings.json
122
+ is treated as CLI."""
123
+ settings = tmp_path / "settings.json"
124
+ settings.write_text(json.dumps(_settings_with_cli_hooks()))
125
+ monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings))
126
+ monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
127
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(tmp_path / "coach"))
128
+ assert cc.main() == 10