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