@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.
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/coach/README.md +99 -0
- package/coach/bin/aggregate_facets.py +274 -0
- package/coach/bin/analyze.py +678 -0
- package/coach/bin/bank.py +247 -0
- package/coach/bin/banner_themes.py +645 -0
- package/coach/bin/coach_paths.py +33 -0
- package/coach/bin/coexistence_check.py +129 -0
- package/coach/bin/configure.py +245 -0
- package/coach/bin/cron_check.py +81 -0
- package/coach/bin/default_statusline.py +135 -0
- package/coach/bin/doctor.py +663 -0
- package/coach/bin/insights-llm.sh +264 -0
- package/coach/bin/insights.sh +163 -0
- package/coach/bin/insights_window.py +111 -0
- package/coach/bin/marker_io.py +154 -0
- package/coach/bin/merge.py +671 -0
- package/coach/bin/redact.py +86 -0
- package/coach/bin/render_env.py +148 -0
- package/coach/bin/reward_hints.py +87 -0
- package/coach/bin/run-insights.sh +20 -0
- package/coach/bin/run_with_lock.py +85 -0
- package/coach/bin/scoring.py +260 -0
- package/coach/bin/skill_inventory.py +215 -0
- package/coach/bin/stats.py +459 -0
- package/coach/bin/status.py +293 -0
- package/coach/bin/statusline_self_patch.py +205 -0
- package/coach/bin/statusline_variants.py +146 -0
- package/coach/bin/statusline_wrap.py +244 -0
- package/coach/bin/statusline_wrap_action.py +460 -0
- package/coach/bin/switch_to_plugin.py +256 -0
- package/coach/bin/themes.py +256 -0
- package/coach/bin/user_config.py +176 -0
- package/coach/bin/xp_accounting.py +98 -0
- package/coach/changelog.md +4 -0
- package/coach/default-statusline-command.sh +19 -0
- package/coach/default-statusline-wrap-command.sh +15 -0
- package/coach/profile.yaml +37 -0
- package/coach/tests/conftest.py +13 -0
- package/coach/tests/test_aggregate_facets.py +379 -0
- package/coach/tests/test_analyze_aggregate.py +153 -0
- package/coach/tests/test_analyze_redaction.py +105 -0
- package/coach/tests/test_analyze_strengths.py +165 -0
- package/coach/tests/test_bank_atomic_write.py +61 -0
- package/coach/tests/test_bank_concurrency.py +126 -0
- package/coach/tests/test_banner_themes.py +981 -0
- package/coach/tests/test_celebrate_dedup.py +409 -0
- package/coach/tests/test_coach_paths.py +50 -0
- package/coach/tests/test_coexistence_check.py +128 -0
- package/coach/tests/test_configure.py +258 -0
- package/coach/tests/test_cron_check.py +118 -0
- package/coach/tests/test_cron_nudge_hook.py +134 -0
- package/coach/tests/test_detection_parity.py +105 -0
- package/coach/tests/test_doctor.py +595 -0
- package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
- package/coach/tests/test_hook_module_resolution.py +116 -0
- package/coach/tests/test_hook_relevance.py +996 -0
- package/coach/tests/test_hook_render_env.py +364 -0
- package/coach/tests/test_hook_session_id_guard.py +160 -0
- package/coach/tests/test_insights_llm.py +759 -0
- package/coach/tests/test_insights_llm_venv_path.py +109 -0
- package/coach/tests/test_insights_window.py +237 -0
- package/coach/tests/test_install.py +1150 -0
- package/coach/tests/test_install_pyyaml_fallback.py +142 -0
- package/coach/tests/test_marker_consumption.py +167 -0
- package/coach/tests/test_marker_writer_locking.py +305 -0
- package/coach/tests/test_merge.py +413 -0
- package/coach/tests/test_no_broken_mktemp.py +90 -0
- package/coach/tests/test_render_env.py +137 -0
- package/coach/tests/test_render_env_glyphs.py +119 -0
- package/coach/tests/test_reward_hints.py +59 -0
- package/coach/tests/test_scoring.py +147 -0
- package/coach/tests/test_session_start_weekly_trigger.py +92 -0
- package/coach/tests/test_skill_inventory.py +368 -0
- package/coach/tests/test_stats_hybrid.py +142 -0
- package/coach/tests/test_status_accounting.py +41 -0
- package/coach/tests/test_statusline_failsafe.py +70 -0
- package/coach/tests/test_statusline_self_patch.py +261 -0
- package/coach/tests/test_statusline_variants.py +110 -0
- package/coach/tests/test_statusline_wrap.py +196 -0
- package/coach/tests/test_statusline_wrap_action.py +408 -0
- package/coach/tests/test_switch_to_plugin.py +360 -0
- package/coach/tests/test_themes.py +104 -0
- package/coach/tests/test_user_config.py +160 -0
- package/coach/tests/test_wrap_announce_hook.py +130 -0
- package/coach/tests/test_xp_accounting.py +55 -0
- package/hooks/coach-session-start.py +536 -0
- package/hooks/coach-user-prompt.py +2288 -0
- package/install-launchd.sh +102 -0
- package/install.sh +597 -0
- package/launchd/com.local.claude-coach.plist.template +34 -0
- package/launchd/run-insights.sh +20 -0
- package/npm/coach-claw.js +259 -0
- package/package.json +52 -0
- package/requirements.txt +11 -0
- package/settings-snippet.json +31 -0
- package/skills/coach/SKILL.md +107 -0
- package/skills/coach-insights/SKILL.md +78 -0
- 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
|