@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,288 @@
|
|
|
1
|
+
"""coach-user-prompt.py: bespoke-theme dispatch in _assemble_celebrate_block.
|
|
2
|
+
|
|
3
|
+
Pins the wiring between the hook's celebrate-block assembler and the
|
|
4
|
+
banner_themes module. Specifically:
|
|
5
|
+
|
|
6
|
+
* craft theme + terminal: produces the historical default shape, byte-
|
|
7
|
+
identical to pre-feature output. No regression for the seven default
|
|
8
|
+
themes.
|
|
9
|
+
* bespoke theme + terminal: triggers banner_themes rendering.
|
|
10
|
+
* bespoke theme + ide: bypasses banner_themes (bespoke is terminal-only).
|
|
11
|
+
* banner_themes raises: hook falls through to default rendering — the
|
|
12
|
+
"hook crash never breaks a session" invariant.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib.util
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
import stats
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(autouse=True)
|
|
26
|
+
def _hermetic_stats_globals(monkeypatch):
|
|
27
|
+
monkeypatch.setattr(stats, "LEVELS", stats._build_level_ladder())
|
|
28
|
+
monkeypatch.setattr(stats, "ELO_MIN", 1000)
|
|
29
|
+
monkeypatch.setattr(stats, "ELO_MAX", 2800)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(scope="module")
|
|
33
|
+
def cup():
|
|
34
|
+
"""Load hooks/coach-user-prompt.py with bundle modules importable.
|
|
35
|
+
|
|
36
|
+
The hook's first action is `sys.path.insert(0, COACH_DIR/bin)` where
|
|
37
|
+
COACH_DIR points at the LIVE install (~/.claude/coach). That live
|
|
38
|
+
install may be stale relative to this checkout, so tests must not
|
|
39
|
+
trust whatever banner_themes happens to import during hook load.
|
|
40
|
+
|
|
41
|
+
We work around that here by:
|
|
42
|
+
1. Evicting any cached versions of the modules the hook will load.
|
|
43
|
+
2. Making bundle bin importable before executing the hook.
|
|
44
|
+
3. After exec, always evicting/re-importing banner_themes from the
|
|
45
|
+
bundle and patching the cup module so assertions pin source-tree
|
|
46
|
+
behavior, not installed-state behavior.
|
|
47
|
+
|
|
48
|
+
Production hook behavior is unchanged — this is purely for dev-time
|
|
49
|
+
test hermeticity."""
|
|
50
|
+
import sys
|
|
51
|
+
bundle_bin = str(Path(__file__).resolve().parents[2] / "coach" / "bin")
|
|
52
|
+
|
|
53
|
+
# Evict cached versions before loading.
|
|
54
|
+
for name in ("render_env", "banner_themes", "stats",
|
|
55
|
+
"user_config", "themes"):
|
|
56
|
+
sys.modules.pop(name, None)
|
|
57
|
+
|
|
58
|
+
# Add bundle bin to sys.path (front). The hook's own insert at line
|
|
59
|
+
# 48 will bump this to position 1, but Python's import walks the
|
|
60
|
+
# whole list — bundle's banner_themes will be found there.
|
|
61
|
+
if bundle_bin not in sys.path:
|
|
62
|
+
sys.path.insert(0, bundle_bin)
|
|
63
|
+
|
|
64
|
+
repo_path = Path(__file__).resolve().parents[2] / "hooks" / "coach-user-prompt.py"
|
|
65
|
+
path = repo_path if repo_path.exists() else Path.home() / ".claude" / "hooks" / "coach-user-prompt.py"
|
|
66
|
+
if not path.exists():
|
|
67
|
+
pytest.skip(f"hook not installed at {path}")
|
|
68
|
+
spec = importlib.util.spec_from_file_location("cup_bespoke_dispatch", str(path))
|
|
69
|
+
mod = importlib.util.module_from_spec(spec)
|
|
70
|
+
spec.loader.exec_module(mod)
|
|
71
|
+
|
|
72
|
+
# ALWAYS force-load banner_themes from the bundle (not the live
|
|
73
|
+
# install). The hook's own `sys.path.insert(0, COACH_DIR/bin)` at
|
|
74
|
+
# line 48 puts the live install ahead of bundle, so without this
|
|
75
|
+
# evict-and-reimport step the test pins assertions against whatever
|
|
76
|
+
# banner_themes happens to be installed at ~/.claude/coach/bin/. We
|
|
77
|
+
# want tests to verify the BUNDLE's behavior — that's source of truth.
|
|
78
|
+
for name in ("render_env", "banner_themes"):
|
|
79
|
+
sys.modules.pop(name, None)
|
|
80
|
+
# Bundle bin must be ahead of live install for the re-import.
|
|
81
|
+
sys.path.insert(0, bundle_bin)
|
|
82
|
+
try:
|
|
83
|
+
from banner_themes import ( # noqa: E402
|
|
84
|
+
render_celebrate_for_theme,
|
|
85
|
+
BESPOKE_THEMES,
|
|
86
|
+
)
|
|
87
|
+
mod._render_celebrate_for_theme = render_celebrate_for_theme
|
|
88
|
+
mod._BESPOKE_THEMES = BESPOKE_THEMES
|
|
89
|
+
mod._BESPOKE_OK = True
|
|
90
|
+
except Exception as e:
|
|
91
|
+
pytest.skip(f"could not load banner_themes from bundle: {e}")
|
|
92
|
+
return mod
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
NOW = datetime(2026, 5, 7, 17, 44, tzinfo=timezone.utc)
|
|
96
|
+
YESTERDAY = datetime(2026, 5, 6, 19, 0, tzinfo=timezone.utc)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _streak_fixture():
|
|
100
|
+
return [
|
|
101
|
+
{"id": "safe-git-hygiene", "name": "safe git hygiene",
|
|
102
|
+
"streak": 4, "target": 5, "xp_awarded": 2, "direction": "positive"},
|
|
103
|
+
{"id": "heavy-subagent-delegation", "name": "heavy subagent delegation",
|
|
104
|
+
"streak": 4, "target": 5, "xp_awarded": 2, "direction": "negative"},
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# -----------------------------------------------------------------------------
|
|
109
|
+
# Default themes — craft / cosmic / marvel / dc / finalfantasy / lotr /
|
|
110
|
+
# starwars MUST render the historical default shape. No bespoke dispatch.
|
|
111
|
+
|
|
112
|
+
def test_craft_theme_terminal_renders_default_shape(cup):
|
|
113
|
+
"""craft + terminal must produce the default `> ↑ name 🔴🔴🔴🔴⚪ ...`
|
|
114
|
+
shape — byte-for-byte regression guard for the seven untouched themes."""
|
|
115
|
+
block = cup._assemble_celebrate_block(
|
|
116
|
+
grads=[],
|
|
117
|
+
regs=[],
|
|
118
|
+
streak_rewards=_streak_fixture(),
|
|
119
|
+
levelup=None,
|
|
120
|
+
caught_up=False,
|
|
121
|
+
env="terminal",
|
|
122
|
+
theme="craft",
|
|
123
|
+
now=NOW,
|
|
124
|
+
streak_oldest=YESTERDAY,
|
|
125
|
+
)
|
|
126
|
+
assert block is not None
|
|
127
|
+
# Default streak rendering uses 🔴⚪ meter and inline backtick spans.
|
|
128
|
+
# If a regression flips this back to a bespoke shape, this test fails.
|
|
129
|
+
assert "> ↑ `safe git hygiene` `🔴🔴🔴🔴⚪` 4/5 · `+2`" in block
|
|
130
|
+
assert "> ↓ `heavy subagent delegation` `🔴🔴🔴🔴⚪` 4/5 · `-2`" in block
|
|
131
|
+
# Bespoke header / glyphs MUST NOT appear.
|
|
132
|
+
assert "Tide turned" not in block
|
|
133
|
+
assert "🦞" not in block
|
|
134
|
+
assert "≋" not in block
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_default_theme_with_caught_up_keeps_framing_line(cup):
|
|
138
|
+
"""The catch-up framing line is part of the default shape and stays
|
|
139
|
+
in place for default themes — only bespoke themes drop it."""
|
|
140
|
+
block = cup._assemble_celebrate_block(
|
|
141
|
+
grads=[],
|
|
142
|
+
regs=[],
|
|
143
|
+
streak_rewards=_streak_fixture(),
|
|
144
|
+
levelup=None,
|
|
145
|
+
caught_up=True,
|
|
146
|
+
env="terminal",
|
|
147
|
+
theme="cosmic",
|
|
148
|
+
now=NOW,
|
|
149
|
+
streak_oldest=YESTERDAY,
|
|
150
|
+
)
|
|
151
|
+
assert block is not None
|
|
152
|
+
assert "Milestones earned across earlier sessions" in block
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# -----------------------------------------------------------------------------
|
|
156
|
+
# Bespoke themes — terminal triggers banner_themes; IDE keeps default.
|
|
157
|
+
|
|
158
|
+
def test_ocean_theme_terminal_uses_bespoke_render(cup):
|
|
159
|
+
block = cup._assemble_celebrate_block(
|
|
160
|
+
grads=[],
|
|
161
|
+
regs=[],
|
|
162
|
+
streak_rewards=_streak_fixture(),
|
|
163
|
+
levelup=None,
|
|
164
|
+
caught_up=True,
|
|
165
|
+
env="terminal",
|
|
166
|
+
theme="ocean",
|
|
167
|
+
now=NOW,
|
|
168
|
+
streak_oldest=YESTERDAY,
|
|
169
|
+
)
|
|
170
|
+
assert block is not None
|
|
171
|
+
assert "🦞 Tide turned · since yesterday" in block
|
|
172
|
+
assert "≋≋≋≋· safe git hygiene" in block
|
|
173
|
+
# Catch-up framing line is REMOVED for bespoke themes — header carries
|
|
174
|
+
# the date instead.
|
|
175
|
+
assert "Milestones earned across earlier sessions" not in block
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_ocean_theme_ide_falls_back_to_default(cup):
|
|
179
|
+
"""IDE rendering is terminal-only for bespoke themes. ocean + ide must
|
|
180
|
+
produce the default HR-framed shape, not bespoke ASCII frames."""
|
|
181
|
+
block = cup._assemble_celebrate_block(
|
|
182
|
+
grads=[],
|
|
183
|
+
regs=[],
|
|
184
|
+
streak_rewards=_streak_fixture(),
|
|
185
|
+
levelup=None,
|
|
186
|
+
caught_up=False,
|
|
187
|
+
env="ide",
|
|
188
|
+
theme="ocean",
|
|
189
|
+
now=NOW,
|
|
190
|
+
streak_oldest=YESTERDAY,
|
|
191
|
+
)
|
|
192
|
+
assert block is not None
|
|
193
|
+
# IDE shape uses HR frames; bespoke ocean header MUST NOT appear.
|
|
194
|
+
assert "Tide turned" not in block
|
|
195
|
+
assert "🦞 Tide turned" not in block
|
|
196
|
+
assert "≋≋≋≋·" not in block
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_hacker_theme_terminal_uses_bespoke_render(cup):
|
|
200
|
+
block = cup._assemble_celebrate_block(
|
|
201
|
+
grads=[],
|
|
202
|
+
regs=[],
|
|
203
|
+
streak_rewards=_streak_fixture(),
|
|
204
|
+
levelup={"to": "Hacker", "to_idx": 7, "xp_at_levelup": 90},
|
|
205
|
+
caught_up=False,
|
|
206
|
+
env="terminal",
|
|
207
|
+
theme="hacker",
|
|
208
|
+
now=NOW,
|
|
209
|
+
streak_oldest=YESTERDAY,
|
|
210
|
+
)
|
|
211
|
+
assert block is not None
|
|
212
|
+
assert "[coach@claw ~]$ tail -f session.log" in block
|
|
213
|
+
assert "safe_git_hygiene" in block
|
|
214
|
+
# Direction prefix on each row — RUN for positive, KILL for negative.
|
|
215
|
+
assert "RUN safe_git_hygiene" in block
|
|
216
|
+
assert "KILL heavy_subagent_delegation" in block
|
|
217
|
+
# XP column uses [↑N xp] for both directions (gain in either case).
|
|
218
|
+
assert "[↑2 xp]" in block
|
|
219
|
+
assert "UPLINK ↑ L8 / Hacker 🥷" in block
|
|
220
|
+
assert "next breach 🔓 125 xp" in block
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_military_theme_terminal_uses_bespoke_render(cup):
|
|
224
|
+
block = cup._assemble_celebrate_block(
|
|
225
|
+
grads=[],
|
|
226
|
+
regs=[],
|
|
227
|
+
streak_rewards=_streak_fixture(),
|
|
228
|
+
levelup={"to": "Sensei", "to_idx": 7, "xp_at_levelup": 90},
|
|
229
|
+
caught_up=False,
|
|
230
|
+
env="terminal",
|
|
231
|
+
theme="military",
|
|
232
|
+
now=NOW,
|
|
233
|
+
streak_oldest=YESTERDAY,
|
|
234
|
+
)
|
|
235
|
+
assert block is not None
|
|
236
|
+
assert "SITREP" in block
|
|
237
|
+
assert "[PUSH] ▮▮▮▮▯ safe git hygiene" in block
|
|
238
|
+
assert "🎖️🎖️" in block # 2 medals at L8
|
|
239
|
+
assert "Ⅷ" in block
|
|
240
|
+
assert "**Sensei**" in block
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# -----------------------------------------------------------------------------
|
|
244
|
+
# Failure path — if banner_themes raises, the hook must fall back to
|
|
245
|
+
# default rendering. Pins the "hook crash never breaks a session" invariant.
|
|
246
|
+
|
|
247
|
+
def test_bespoke_render_failure_falls_back_to_default(cup, monkeypatch):
|
|
248
|
+
"""Inject an exception into banner_themes.render_celebrate_for_theme
|
|
249
|
+
and verify the hook still produces a default-shape banner."""
|
|
250
|
+
def boom(*args, **kwargs):
|
|
251
|
+
raise RuntimeError("simulated bespoke crash")
|
|
252
|
+
|
|
253
|
+
monkeypatch.setattr(cup, "_render_celebrate_for_theme", boom)
|
|
254
|
+
block = cup._assemble_celebrate_block(
|
|
255
|
+
grads=[],
|
|
256
|
+
regs=[],
|
|
257
|
+
streak_rewards=_streak_fixture(),
|
|
258
|
+
levelup=None,
|
|
259
|
+
caught_up=False,
|
|
260
|
+
env="terminal",
|
|
261
|
+
theme="ocean",
|
|
262
|
+
now=NOW,
|
|
263
|
+
streak_oldest=YESTERDAY,
|
|
264
|
+
)
|
|
265
|
+
# Must produce a non-None banner (default shape) despite the crash.
|
|
266
|
+
assert block is not None
|
|
267
|
+
# Default shape markers — same as the craft regression test above.
|
|
268
|
+
assert "> ↑ `safe git hygiene`" in block
|
|
269
|
+
# No bespoke leakage.
|
|
270
|
+
assert "🦞" not in block
|
|
271
|
+
assert "≋" not in block
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_bespoke_dispatch_returns_none_when_nothing_to_render(cup):
|
|
275
|
+
"""No streak rewards, no levelup, no grads/regs → None.
|
|
276
|
+
Mirrors the default path's empty-input behavior."""
|
|
277
|
+
block = cup._assemble_celebrate_block(
|
|
278
|
+
grads=[],
|
|
279
|
+
regs=[],
|
|
280
|
+
streak_rewards=[],
|
|
281
|
+
levelup=None,
|
|
282
|
+
caught_up=False,
|
|
283
|
+
env="terminal",
|
|
284
|
+
theme="ocean",
|
|
285
|
+
now=NOW,
|
|
286
|
+
streak_oldest=None,
|
|
287
|
+
)
|
|
288
|
+
assert block is None
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Regression: hooks must use the bin/ that ships WITH them.
|
|
2
|
+
|
|
3
|
+
Discovered during e2e validation (2026-05-09): the plugin's hooks were
|
|
4
|
+
putting `~/.claude/coach/bin/` (the CLI's install dir) on sys.path
|
|
5
|
+
instead of `${CLAUDE_PLUGIN_ROOT}/bin/`. When a user had the npm CLI
|
|
6
|
+
installed at an older version that pre-dated newer plugin-track
|
|
7
|
+
modules (cron_check, statusline_self_patch, etc.), the plugin's hook
|
|
8
|
+
would silently fall back to the CLI's stale modules — and the imports
|
|
9
|
+
inside `_maybe_install_plugin_statusline` and
|
|
10
|
+
`_maybe_cron_nudge_block` would fail with `ModuleNotFoundError`,
|
|
11
|
+
suppressed by the failsafe try/except.
|
|
12
|
+
|
|
13
|
+
Net effect: the plugin's hook fired, but the new plugin-track
|
|
14
|
+
behaviors silently no-op'd. statusLine never self-installed. Cron
|
|
15
|
+
nudge never appeared. No error. No log line.
|
|
16
|
+
|
|
17
|
+
Both hooks now branch on `CLAUDE_PLUGIN_ROOT`: if set, prefer the
|
|
18
|
+
plugin's bin/; otherwise use the CLI's. This test pins that branch
|
|
19
|
+
by importing each hook under controlled env vars and inspecting
|
|
20
|
+
which path landed at `sys.path[0]`.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import importlib.util
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
31
|
+
SESSION_START_HOOK = REPO_ROOT / "hooks" / "coach-session-start.py"
|
|
32
|
+
USER_PROMPT_HOOK = REPO_ROOT / "hooks" / "coach-user-prompt.py"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _import_isolated(path: Path, env_vars: dict[str, str], monkeypatch):
|
|
36
|
+
"""Load a hook module with monkeypatched env vars + isolated sys.path.
|
|
37
|
+
|
|
38
|
+
Returns sys.path BEFORE the hook ran (saved snapshot) plus the
|
|
39
|
+
paths the hook prepended (everything new at the front of sys.path).
|
|
40
|
+
"""
|
|
41
|
+
saved = list(sys.path)
|
|
42
|
+
# Drop any previous hook import cached in sys.modules so module-load
|
|
43
|
+
# side effects re-execute under the new env.
|
|
44
|
+
for mod_name in list(sys.modules):
|
|
45
|
+
if "cup_under_test" in mod_name or "css_under_test" in mod_name:
|
|
46
|
+
sys.modules.pop(mod_name)
|
|
47
|
+
|
|
48
|
+
for k in ("CLAUDE_PLUGIN_ROOT", "COACH_CONFIG_DIR"):
|
|
49
|
+
monkeypatch.delenv(k, raising=False)
|
|
50
|
+
for k, v in env_vars.items():
|
|
51
|
+
monkeypatch.setenv(k, v)
|
|
52
|
+
|
|
53
|
+
name = "css_under_test" if "session-start" in path.name else "cup_under_test"
|
|
54
|
+
spec = importlib.util.spec_from_file_location(name, str(path))
|
|
55
|
+
mod = importlib.util.module_from_spec(spec)
|
|
56
|
+
spec.loader.exec_module(mod)
|
|
57
|
+
|
|
58
|
+
# Anything in sys.path that wasn't there before are the prepends.
|
|
59
|
+
new_paths = [p for p in sys.path if p not in saved]
|
|
60
|
+
return new_paths, mod
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def fake_plugin(tmp_path):
|
|
65
|
+
"""Two parallel bin dirs in tmp: a 'plugin' bin and a 'cli' bin.
|
|
66
|
+
Tests verify which one lands on sys.path."""
|
|
67
|
+
plugin_root = tmp_path / "plugin"
|
|
68
|
+
(plugin_root / "bin").mkdir(parents=True)
|
|
69
|
+
coach_dir = tmp_path / "coach"
|
|
70
|
+
(coach_dir / "bin").mkdir(parents=True)
|
|
71
|
+
return plugin_root, coach_dir
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize("hook_path", [SESSION_START_HOOK, USER_PROMPT_HOOK])
|
|
75
|
+
def test_plugin_context_uses_plugin_bin(hook_path, fake_plugin, monkeypatch):
|
|
76
|
+
plugin_root, coach_dir = fake_plugin
|
|
77
|
+
new_paths, _ = _import_isolated(
|
|
78
|
+
hook_path,
|
|
79
|
+
{
|
|
80
|
+
"CLAUDE_PLUGIN_ROOT": str(plugin_root),
|
|
81
|
+
"COACH_CONFIG_DIR": str(coach_dir),
|
|
82
|
+
},
|
|
83
|
+
monkeypatch,
|
|
84
|
+
)
|
|
85
|
+
plugin_bin = str(plugin_root / "bin")
|
|
86
|
+
cli_bin = str(coach_dir / "bin")
|
|
87
|
+
# plugin bin should be the FIRST insertion (sys.path[0])
|
|
88
|
+
assert new_paths and new_paths[0] == plugin_bin, (
|
|
89
|
+
f"With CLAUDE_PLUGIN_ROOT set, hook must put ${{CLAUDE_PLUGIN_ROOT}}/bin/ "
|
|
90
|
+
f"on sys.path. Expected first prepend = {plugin_bin!r}; got new paths = "
|
|
91
|
+
f"{new_paths!r}"
|
|
92
|
+
)
|
|
93
|
+
# CLI bin must NOT be on sys.path in plugin context — using stale CLI
|
|
94
|
+
# modules is the bug this test pins.
|
|
95
|
+
assert cli_bin not in sys.path, (
|
|
96
|
+
f"CLI bin {cli_bin!r} should NOT be on sys.path under plugin context. "
|
|
97
|
+
f"Got sys.path entries: {[p for p in sys.path if 'bin' in p]!r}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize("hook_path", [SESSION_START_HOOK, USER_PROMPT_HOOK])
|
|
102
|
+
def test_cli_context_uses_coach_bin(hook_path, fake_plugin, monkeypatch):
|
|
103
|
+
"""Without CLAUDE_PLUGIN_ROOT, hooks fall back to ${COACH_DIR}/bin/
|
|
104
|
+
(the CLI install layout)."""
|
|
105
|
+
_, coach_dir = fake_plugin
|
|
106
|
+
new_paths, _ = _import_isolated(
|
|
107
|
+
hook_path,
|
|
108
|
+
{"COACH_CONFIG_DIR": str(coach_dir)},
|
|
109
|
+
monkeypatch,
|
|
110
|
+
)
|
|
111
|
+
cli_bin = str(coach_dir / "bin")
|
|
112
|
+
assert new_paths and new_paths[0] == cli_bin, (
|
|
113
|
+
f"Without CLAUDE_PLUGIN_ROOT, hook must put ${{COACH_CONFIG_DIR}}/bin/ "
|
|
114
|
+
f"on sys.path. Expected first prepend = {cli_bin!r}; got new paths = "
|
|
115
|
+
f"{new_paths!r}"
|
|
116
|
+
)
|