@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,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
+ )