@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,364 @@
1
+ """coach-user-prompt.py: render-shape branching by environment.
2
+
3
+ Verifies each of the six render functions emits the right shape per env:
4
+ - terminal: blockquote `> ` shape (current default, must not regress)
5
+ - ide: HR-framed `---` shape with bold + code-span pills
6
+
7
+ For celebrate banners (streak/graduation/regression/levelup) the hook
8
+ now emits the **final banner markdown verbatim** — Claude reproduces it
9
+ unchanged. So these assertions pin the literal banner text, not
10
+ instruction templates.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
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_renderenv", str(path))
27
+ mod = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(mod)
29
+ return mod
30
+
31
+
32
+ # -----------------------------------------------------------------------------
33
+ # _ide_label transformations
34
+ # -----------------------------------------------------------------------------
35
+
36
+ def test_ide_label_strips_italics_and_colon(cup):
37
+ assert cup._ide_label("*Tip:*") == "🦞 **Tip**"
38
+ assert cup._ide_label("*Pointer:*") == "🦞 **Pointer**"
39
+
40
+
41
+ def test_ide_label_strips_leading_emoji_token(cup):
42
+ assert cup._ide_label("*🎯 Tip:*") == "🦞 **Tip**"
43
+ assert cup._ide_label("*✏️ Tip:*") == "🦞 **Tip**"
44
+ assert cup._ide_label("*🧭 Heads up:*") == "🦞 **Heads up**"
45
+
46
+
47
+ def test_ide_label_strips_redundant_lobster(cup):
48
+ """Skill labels already have 🦞; the helper drops it then re-adds the
49
+ persona prefix so the output never has 🦞🦞."""
50
+ assert cup._ide_label("*🦞 From Coach Claw:*") == "🦞 **From Coach Claw**"
51
+ assert cup._ide_label("*🦞 Coach:*") == "🦞 **Coach**"
52
+
53
+
54
+ def test_ide_label_handles_multiword_label(cup):
55
+ assert cup._ide_label("*Worth noting:*") == "🦞 **Worth noting**"
56
+ assert cup._ide_label("*Good pattern:*") == "🦞 **Good pattern**"
57
+
58
+
59
+ def test_ide_label_always_prefixes_lobster(cup):
60
+ """Every IDE label leads with 🦞 — universal coach signature."""
61
+ for label in cup.WEAKNESS_LABELS + cup.STRENGTH_LABELS + cup.SKILL_LABELS:
62
+ assert cup._ide_label(label).startswith("🦞 **")
63
+
64
+
65
+ # -----------------------------------------------------------------------------
66
+ # _xp_attribution — terminal vs IDE shape
67
+ # -----------------------------------------------------------------------------
68
+
69
+ def test_xp_attribution_terminal_uses_italics(cup):
70
+ """Terminal shape: lines wrapped in `_..._` for theme-driven dim."""
71
+ lines = cup._xp_attribution(
72
+ {"kind": "weakness", "entry_id": "edits-without-testing", "clean_streak": 2,
73
+ "reward_hint": {"action": "test_run", "xp": 2, "description": "test run"}},
74
+ env="terminal",
75
+ )
76
+ assert all(line.startswith("_") and line.endswith("_") for line in lines)
77
+ assert all("`" not in line for line in lines) # no code spans in terminal shape
78
+
79
+
80
+ def test_xp_attribution_ide_uses_code_span_pills(cup):
81
+ """IDE shape: lines wrapped in backticks → pill backgrounds."""
82
+ lines = cup._xp_attribution(
83
+ {"kind": "weakness", "entry_id": "edits-without-testing", "clean_streak": 2,
84
+ "reward_hint": {"action": "test_run", "xp": 2, "description": "test run"}},
85
+ env="ide",
86
+ )
87
+ assert all(line.startswith("`") and line.endswith("`") for line in lines)
88
+ assert all("_" not in line for line in lines) # no italic markers
89
+
90
+
91
+ def test_xp_attribution_ide_skill_single_line(cup):
92
+ """Skills have only one attribution line in both envs."""
93
+ lines = cup._xp_attribution(
94
+ {"kind": "skill", "entry_id": "deploy-to-vercel"}, env="ide"
95
+ )
96
+ assert len(lines) == 1
97
+ assert lines[0].startswith("`↑ +")
98
+ assert "/deploy-to-vercel" in lines[0]
99
+
100
+
101
+ def test_xp_attribution_default_env_is_terminal(cup):
102
+ """No env arg = terminal shape (backward compat)."""
103
+ lines = cup._xp_attribution({"kind": "skill", "entry_id": "test-skill"})
104
+ assert lines[0].startswith("_↑ +")
105
+
106
+
107
+ # -----------------------------------------------------------------------------
108
+ # _levelup_block — terminal vs IDE
109
+ # -----------------------------------------------------------------------------
110
+
111
+ def test_levelup_terminal_uses_blockquote(cup):
112
+ block = cup._levelup_block(
113
+ {"from": "L3 Practitioner", "to": "Reviewer", "to_idx": 3, "xp_at_levelup": 1247},
114
+ env="terminal",
115
+ )
116
+ # Verbatim banner: title and stock body, both blockquote-prefixed.
117
+ assert "> 🎉 **Level up!** You're now **L4 Reviewer**." in block
118
+ assert "> A new craft tier unlocks at 1247 XP." in block
119
+ assert "---" not in block
120
+
121
+
122
+ def test_levelup_ide_uses_hr_frame(cup):
123
+ block = cup._levelup_block(
124
+ {"from": "L3 Practitioner", "to": "Reviewer", "to_idx": 3, "xp_at_levelup": 1247},
125
+ env="ide",
126
+ )
127
+ # Banner emitted at column 0 (HR-framed). Hook used to emit a
128
+ # 4-space indented template inside an instruction block; verbatim
129
+ # render means no leading indentation.
130
+ assert "🎉 **LEVEL UP** — `L4 Reviewer` · `1247 XP total`" in block
131
+ assert "A new craft tier unlocks." in block
132
+ assert block.startswith("---\n")
133
+ assert block.endswith("\n---")
134
+ # Setext-H2 guard: bottom `---` must be preceded by a blank line.
135
+ assert "\n\n---" in block
136
+ assert "> " not in block # no terminal blockquote signature
137
+
138
+
139
+ # -----------------------------------------------------------------------------
140
+ # _regression_block — terminal vs IDE
141
+ # -----------------------------------------------------------------------------
142
+
143
+ def test_regression_terminal_uses_blockquote(cup):
144
+ block = cup._regression_block(
145
+ [{"id": "edits-without-testing", "name": "Edits without testing",
146
+ "originally_graduated_at": "2026-04-01"}],
147
+ env="terminal",
148
+ )
149
+ # Verbatim banner: name (not slug) appears in the heading; the
150
+ # graduation date is interpolated into the body sentence.
151
+ assert "> ⚠️ **Regressed: Edits without testing**" in block
152
+ assert "(was graduated 2026-04-01)" in block
153
+ assert "edits-without-testing" not in block # slug must not leak
154
+ assert "---" not in block
155
+
156
+
157
+ def test_regression_ide_uses_hr_frame(cup):
158
+ block = cup._regression_block(
159
+ [{"id": "edits-without-testing", "name": "Edits without testing",
160
+ "originally_graduated_at": "2026-04-01"}],
161
+ env="ide",
162
+ )
163
+ assert "⚠️ **Regressed** — `Edits without testing`" in block
164
+ assert "edits-without-testing" not in block # slug must not leak
165
+ assert block.startswith("---\n")
166
+ assert block.endswith("\n---")
167
+ assert "\n\n---" in block # Setext-H2 guard
168
+
169
+
170
+ # -----------------------------------------------------------------------------
171
+ # _streak_reward_block — terminal vs IDE
172
+ # -----------------------------------------------------------------------------
173
+
174
+ def test_streak_reward_terminal_negative(cup):
175
+ """Negative direction → ↓ arrow, name (not slug) in body."""
176
+ block = cup._streak_reward_block(
177
+ [{"id": "edits-without-testing", "name": "edits without testing",
178
+ "direction": "negative", "streak": 3, "target": 5, "xp_awarded": 1}],
179
+ env="terminal",
180
+ )
181
+ expected = "> ↓ `edits without testing` `🔴🔴🔴⚪⚪` 3/5 · `-1`"
182
+ assert expected in block
183
+ assert "↑" not in block
184
+ assert "edits-without-testing" not in block # slug must not leak
185
+
186
+
187
+ def test_streak_reward_terminal_positive(cup):
188
+ """Positive direction → ↑ arrow."""
189
+ block = cup._streak_reward_block(
190
+ [{"id": "safe-git-hygiene", "name": "safe git hygiene",
191
+ "direction": "positive", "streak": 4, "target": 5, "xp_awarded": 2}],
192
+ env="terminal",
193
+ )
194
+ expected = "> ↑ `safe git hygiene` `🔴🔴🔴🔴⚪` 4/5 · `+2`"
195
+ assert expected in block
196
+ assert "↓" not in block
197
+
198
+
199
+ def test_streak_reward_ide_negative(cup):
200
+ block = cup._streak_reward_block(
201
+ [{"id": "edits-without-testing", "name": "edits without testing",
202
+ "direction": "negative", "streak": 3, "target": 5, "xp_awarded": 1}],
203
+ env="ide",
204
+ )
205
+ expected = "↓ `edits without testing` · `🔴🔴🔴⚪⚪ 3/5` · `-1`"
206
+ assert expected in block
207
+ assert "↑" not in block
208
+ assert block.startswith("---\n")
209
+ assert block.endswith("\n---")
210
+ assert "\n\n---" in block # Setext-H2 guard
211
+
212
+
213
+ def test_streak_reward_ide_positive(cup):
214
+ block = cup._streak_reward_block(
215
+ [{"id": "safe-git-hygiene", "name": "safe git hygiene",
216
+ "direction": "positive", "streak": 4, "target": 5, "xp_awarded": 2}],
217
+ env="ide",
218
+ )
219
+ expected = "↑ `safe git hygiene` · `🔴🔴🔴🔴⚪ 4/5` · `+2`"
220
+ assert expected in block
221
+ assert "↓" not in block
222
+
223
+
224
+ # -----------------------------------------------------------------------------
225
+ # _graduation_block — terminal vs IDE, both directions
226
+ # -----------------------------------------------------------------------------
227
+
228
+ def test_graduation_terminal_negative(cup):
229
+ """Negative graduation → GRADUATED ⚡️ shape with weakness-retired body.
230
+ No POSITIVE shape may leak into the output (verbatim, not template)."""
231
+ block = cup._graduation_block(
232
+ [{"id": "edits-without-testing", "name": "edits without testing",
233
+ "direction": "negative", "graduated_reason": "absent-5-runs"}],
234
+ env="terminal",
235
+ )
236
+ assert "> 🎓⚡️ **GRADUATED: edits without testing** `+5 XP`" in block
237
+ assert "5 clean Coach insights runs in a row — weakness retired." in block
238
+ assert "MASTERED" not in block # positive shape must NOT appear
239
+ assert "core strength" not in block
240
+ assert "edits-without-testing" not in block # slug must not leak
241
+ assert "---" not in block
242
+
243
+
244
+ def test_graduation_terminal_positive(cup):
245
+ """Positive graduation → MASTERED 🌟 shape with core-strength body.
246
+ No NEGATIVE shape may leak (the original bug)."""
247
+ block = cup._graduation_block(
248
+ [{"id": "safe-git-hygiene", "name": "safe git hygiene",
249
+ "direction": "positive", "graduated_reason": "present-5-runs"}],
250
+ env="terminal",
251
+ )
252
+ assert "> 🎓🌟 **MASTERED: safe git hygiene** `+5 XP`" in block
253
+ assert "core strength" in block
254
+ assert "GRADUATED" not in block # negative shape must NOT appear
255
+ assert "weakness retired" not in block # the original bug — must stay gone
256
+ assert "safe-git-hygiene" not in block # slug must not leak
257
+ assert "---" not in block
258
+
259
+
260
+ def test_graduation_ide_negative(cup):
261
+ block = cup._graduation_block(
262
+ [{"id": "edits-without-testing", "name": "edits without testing",
263
+ "direction": "negative", "graduated_reason": "absent-5-runs"}],
264
+ env="ide",
265
+ )
266
+ assert "🎓 **GRADUATED** ⚡ — `edits without testing` · `+5 XP`" in block
267
+ assert "weakness retired" in block
268
+ assert "MASTERED" not in block # positive shape must NOT appear
269
+ assert block.startswith("---\n")
270
+ assert block.endswith("\n---")
271
+ assert "\n\n---" in block # Setext-H2 guard
272
+
273
+
274
+ def test_graduation_ide_positive(cup):
275
+ block = cup._graduation_block(
276
+ [{"id": "safe-git-hygiene", "name": "safe git hygiene",
277
+ "direction": "positive", "graduated_reason": "present-5-runs"}],
278
+ env="ide",
279
+ )
280
+ assert "🎓 **MASTERED** 🌟 — `safe git hygiene` · `+5 XP`" in block
281
+ assert "core strength" in block
282
+ assert "GRADUATED" not in block # negative shape must NOT appear
283
+ assert block.startswith("---\n")
284
+ assert block.endswith("\n---")
285
+ assert "\n\n---" in block # Setext-H2 guard
286
+
287
+
288
+ # -----------------------------------------------------------------------------
289
+ # _completion_banner — terminal vs IDE, all kinds
290
+ # -----------------------------------------------------------------------------
291
+
292
+ def test_completion_banner_terminal_skill(cup):
293
+ block = cup._completion_banner(
294
+ [("entry:deploy-to-vercel", {
295
+ "kind": "skill", "entry_id": "deploy-to-vercel",
296
+ "spec": {"action": "skill_invoke", "skill_id": "deploy-to-vercel"},
297
+ })],
298
+ env="terminal",
299
+ )
300
+ assert "> ✅ **Tip cleared** — `/deploy-to-vercel` invoked" in block
301
+ assert "---" not in block
302
+
303
+
304
+ def test_completion_banner_ide_skill(cup):
305
+ block = cup._completion_banner(
306
+ [("entry:deploy-to-vercel", {
307
+ "kind": "skill", "entry_id": "deploy-to-vercel",
308
+ "spec": {"action": "skill_invoke", "skill_id": "deploy-to-vercel"},
309
+ })],
310
+ env="ide",
311
+ )
312
+ assert " ---" in block
313
+ assert "✅ **Tip cleared** — `/deploy-to-vercel` invoked" in block
314
+ assert "\n\n ---" in block # Setext-H2 guard
315
+
316
+
317
+ def test_completion_banner_ide_weakness(cup):
318
+ block = cup._completion_banner(
319
+ [("entry:edits-without-testing", {
320
+ "kind": "weakness", "entry_id": "edits-without-testing",
321
+ "clean_streak": 2,
322
+ "spec": {"action": "test_run", "xp": 2, "description": "test run"},
323
+ })],
324
+ env="ide",
325
+ )
326
+ assert " ---" in block
327
+ assert "✅ **Tip cleared** — `edits-without-testing`" in block
328
+ assert "`+2 XP banked`" in block
329
+ assert "`streak 🔴🔴⚪⚪⚪ advances next /coach-insights`" in block
330
+
331
+
332
+ def test_completion_banner_ide_strength(cup):
333
+ block = cup._completion_banner(
334
+ [("entry:tests-after-edits", {
335
+ "kind": "strength", "entry_id": "tests-after-edits",
336
+ "positive_streak": 2,
337
+ "spec": {"action": "test_run", "xp": 2, "description": "test run"},
338
+ })],
339
+ env="ide",
340
+ )
341
+ assert " ---" in block
342
+ assert "💪 **Strength reinforced** — `tests-after-edits`" in block
343
+ assert "`strength streak 🔴🔴⚪⚪⚪`" in block
344
+
345
+
346
+ # -----------------------------------------------------------------------------
347
+ # All renderers preserve current default behavior when env arg omitted
348
+ # -----------------------------------------------------------------------------
349
+
350
+ @pytest.mark.parametrize("call", [
351
+ lambda c: c._levelup_block({"from": "L3", "to": "Reviewer", "to_idx": 3, "xp_at_levelup": 100}),
352
+ lambda c: c._regression_block([{"id": "p1", "name": "P", "originally_graduated_at": "2026-01-01"}]),
353
+ lambda c: c._streak_reward_block([{"id": "p1", "name": "P", "streak": 1, "target": 5, "xp_awarded": 1}]),
354
+ lambda c: c._graduation_block([{"id": "p1", "name": "P", "direction": "negative", "graduated_reason": "x"}]),
355
+ lambda c: c._completion_banner([("e:p1", {"kind": "skill", "entry_id": "p1",
356
+ "spec": {"action": "skill_invoke", "skill_id": "p1"}})]),
357
+ ])
358
+ def test_omitted_env_arg_preserves_terminal_shape(cup, call):
359
+ """No env arg = terminal shape (backward compat invariant)."""
360
+ block = call(cup)
361
+ assert "> " in block # terminal blockquote present
362
+ # IDE-only signature characters absent
363
+ assert " ---" not in block
364
+ assert " ---\n" not in block
@@ -0,0 +1,160 @@
1
+ """Hook guard: refuse stateful writes when session_id is missing.
2
+
3
+ v0.5.2 fix for two narrow but real bugs surfaced during the uninstall
4
+ e2e:
5
+
6
+ R1 — `coach-session-start.py` spawned `bank.py` and `insights-llm.sh`
7
+ fire-and-forget regardless of payload. An ad-hoc smoke test
8
+ (`echo '{}' | python3 hook.py`) on a freshly-installed empty
9
+ coach dir would let bank.py race against an in-progress restore
10
+ and write a bogus `.pending_levelup` jumping the user from
11
+ Builder → Virtuoso.
12
+
13
+ R2 — `coach-user-prompt.py` fell through to writing an empty/sentinel
14
+ `session_key` into pending markers' `consumed_by` list. Real
15
+ Claude Code sessions then saw the marker as already-consumed
16
+ and skipped rendering.
17
+
18
+ The guard: if no `session_id`/`sessionId`/`transcript_path`/`transcriptPath`
19
+ in the payload, exit 0 silently with NO subprocess spawn and NO marker
20
+ mutation. Real Claude Code events always carry one of those fields; the
21
+ only callers without them are smoke tests and malformed input.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import subprocess
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ import pytest
32
+
33
+
34
+ HOOKS_DIR = Path(__file__).resolve().parents[2] / "hooks"
35
+ SESSION_START = HOOKS_DIR / "coach-session-start.py"
36
+ USER_PROMPT = HOOKS_DIR / "coach-user-prompt.py"
37
+
38
+
39
+ def _run_hook(hook_path: Path, payload: dict, claude_dir: Path) -> subprocess.CompletedProcess:
40
+ """Invoke a hook with the given payload, isolating COACH_DIR via $HOME."""
41
+ env = os.environ.copy()
42
+ env["HOME"] = str(claude_dir.parent)
43
+ # Make sure the hook's `Path.home() / ".claude" / "coach"` resolves into
44
+ # our temp dir, not the user's real install.
45
+ return subprocess.run(
46
+ [sys.executable, str(hook_path)],
47
+ input=json.dumps(payload),
48
+ env=env,
49
+ text=True,
50
+ stdout=subprocess.PIPE,
51
+ stderr=subprocess.PIPE,
52
+ timeout=10,
53
+ )
54
+
55
+
56
+ @pytest.fixture
57
+ def fake_claude_home(tmp_path: Path) -> Path:
58
+ """Build a minimal `~/.claude/coach/` so hooks have somewhere to look."""
59
+ home = tmp_path / "home"
60
+ coach = home / ".claude" / "coach"
61
+ coach.mkdir(parents=True)
62
+ (coach / "bin").mkdir()
63
+ # A real bank.py would fire; we install a sentinel that records its run
64
+ # by touching a file. If the guard works, this file MUST NOT appear.
65
+ sentinel = coach / "bank-was-spawned.sentinel"
66
+ bank_py = coach / "bin" / "bank.py"
67
+ bank_py.write_text(
68
+ f"#!/usr/bin/env python3\n"
69
+ f"from pathlib import Path\n"
70
+ f"Path({str(sentinel)!r}).write_text('spawned')\n"
71
+ )
72
+ bank_py.chmod(0o755)
73
+ return home
74
+
75
+
76
+ def _hook_available(hook: Path) -> bool:
77
+ """Skip when the bundle isn't checked out (e.g. running from ~/.claude/coach/)."""
78
+ return hook.exists()
79
+
80
+
81
+ def test_session_start_with_empty_input_does_nothing(fake_claude_home: Path) -> None:
82
+ """`echo '{}' | coach-session-start.py` exits 0 silently, no bank spawn."""
83
+ if not _hook_available(SESSION_START):
84
+ pytest.skip("hook source not in this checkout")
85
+
86
+ result = _run_hook(SESSION_START, {}, fake_claude_home)
87
+ assert result.returncode == 0
88
+ assert result.stdout.strip() == "", (
89
+ f"hook should produce no output for empty payload; got: {result.stdout!r}"
90
+ )
91
+
92
+ sentinel = fake_claude_home / ".claude" / "coach" / "bank-was-spawned.sentinel"
93
+ # bank.py spawn is detached, so we wait briefly to give a hypothetical
94
+ # spawn time to land. If the guard works, no spawn happened.
95
+ import time
96
+ time.sleep(0.5)
97
+ assert not sentinel.exists(), (
98
+ "bank.py was spawned despite missing session_id — guard regression"
99
+ )
100
+
101
+
102
+ def test_session_start_with_session_id_proceeds(fake_claude_home: Path) -> None:
103
+ """Sanity: a payload WITH session_id should NOT be silenced by the guard.
104
+
105
+ We don't assert bank spawn here (it depends on a profile.yaml that we
106
+ haven't set up); we just assert the hook doesn't bail at the guard.
107
+ The hook is wrapped in a try/except that always exits 0, so we look
108
+ for a side effect: it should at least attempt to read the profile,
109
+ which means it got past the guard. Easiest signal: stdout is allowed
110
+ to be empty (silent because no profile), but exit must be 0 (failsafe).
111
+ """
112
+ if not _hook_available(SESSION_START):
113
+ pytest.skip("hook source not in this checkout")
114
+
115
+ result = _run_hook(SESSION_START, {"session_id": "test-abc"}, fake_claude_home)
116
+ assert result.returncode == 0
117
+
118
+
119
+ def test_user_prompt_with_empty_input_does_not_consume_markers(
120
+ fake_claude_home: Path,
121
+ ) -> None:
122
+ """A pending marker's consumed_by must NOT gain an entry from `{}`."""
123
+ if not _hook_available(USER_PROMPT):
124
+ pytest.skip("hook source not in this checkout")
125
+
126
+ coach = fake_claude_home / ".claude" / "coach"
127
+ marker = coach / ".pending_streak_rewards"
128
+ marker.write_text(json.dumps({
129
+ "rewards": [{"id": "x", "name": "X", "streak": 3}],
130
+ "created_at": "2026-05-01T00:00:00+00:00",
131
+ "consumed_by": [],
132
+ }))
133
+
134
+ result = _run_hook(USER_PROMPT, {}, fake_claude_home)
135
+ assert result.returncode == 0
136
+ assert result.stdout.strip() == "", (
137
+ f"hook should produce no output for empty payload; got: {result.stdout!r}"
138
+ )
139
+
140
+ # The load-bearing assertion: consumed_by stays empty so the marker
141
+ # is still visible to the next REAL session.
142
+ after = json.loads(marker.read_text())
143
+ assert after["consumed_by"] == [], (
144
+ f"empty payload polluted consumed_by: {after['consumed_by']}"
145
+ )
146
+
147
+
148
+ def test_user_prompt_with_transcript_path_proceeds(fake_claude_home: Path) -> None:
149
+ """Payload with transcript_path (no session_id) should still pass the guard."""
150
+ if not _hook_available(USER_PROMPT):
151
+ pytest.skip("hook source not in this checkout")
152
+
153
+ # transcript_path doesn't need to exist for the guard check — that's
154
+ # the job of later confinement logic. The guard is purely a presence test.
155
+ result = _run_hook(
156
+ USER_PROMPT,
157
+ {"transcript_path": "/tmp/nonexistent.jsonl"},
158
+ fake_claude_home,
159
+ )
160
+ assert result.returncode == 0