@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,413 @@
1
+ """merge.py — graduation, mid-streak rewards, reward_hint precedence."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ import yaml
10
+
11
+ import merge
12
+
13
+
14
+ # --- helpers ----------------------------------------------------------------
15
+
16
+ def _now() -> datetime:
17
+ return datetime(2026, 4, 20, 0, 0, 0, tzinfo=timezone.utc)
18
+
19
+
20
+ def _blank_profile() -> dict:
21
+ return {"schema_version": 1, "updated": None, "entries": [], "recent_runs": []}
22
+
23
+
24
+ @pytest.fixture
25
+ def _marker_cleanup(tmp_path, monkeypatch):
26
+ """Redirect marker writes into tmp_path so tests don't pollute ~/.claude."""
27
+ monkeypatch.setattr(merge, "GRADUATION_MARKER", tmp_path / "graduation.json")
28
+ monkeypatch.setattr(merge, "STREAK_REWARD_MARKER", tmp_path / "streak_rewards.json")
29
+ monkeypatch.setattr(merge, "REGRESSION_MARKER", tmp_path / "regression.json")
30
+ yield tmp_path
31
+
32
+
33
+ # --- graduation: negative absence ------------------------------------------
34
+
35
+ def test_negative_graduation_fires_at_clean_streak_5(_marker_cleanup):
36
+ """The P1 #1 fix: clean_streak_runs=4 + empty detections → graduate."""
37
+ profile = _blank_profile()
38
+ profile["entries"] = [{
39
+ "id": "w1", "name": "weakness 1", "tier": "active", "direction": "negative",
40
+ "confidence": 0.8, "priority": 3, "nudge": "x", "examples": [],
41
+ "first_seen": "2026-03-01", "last_seen_in_run": "2026-04-01",
42
+ "clean_streak_runs": 4, "positive_run_streak": 0,
43
+ "source_runs": ["old"], "total_occurrences": 10,
44
+ }]
45
+ profile["recent_runs"] = ["r-a", "r-b", "r-c"]
46
+
47
+ fragments = merge.merge(profile, detections=[], run_id="r-d", now=_now())
48
+
49
+ assert profile["entries"] == []
50
+ assert len(profile["graduated"]) == 1
51
+ g = profile["graduated"][0]
52
+ assert g["id"] == "w1"
53
+ assert g["direction"] == "negative"
54
+ assert g["graduated_reason"] == "absent-5-runs"
55
+ assert any("🎓w1" in f for f in fragments)
56
+
57
+
58
+ def test_negative_absence_below_threshold_does_not_graduate(_marker_cleanup):
59
+ profile = _blank_profile()
60
+ profile["entries"] = [{
61
+ "id": "w1", "name": "weakness 1", "tier": "active", "direction": "negative",
62
+ # last_seen recent enough that confidence decay doesn't push below
63
+ # RETIRE_BELOW — we're isolating the absence-graduation path here.
64
+ "confidence": 0.9, "priority": 3, "nudge": "x", "examples": [],
65
+ "first_seen": "2026-04-19", "last_seen_in_run": "2026-04-19",
66
+ "clean_streak_runs": 2, "positive_run_streak": 0,
67
+ "source_runs": ["old"], "total_occurrences": 10,
68
+ }]
69
+ merge.merge(profile, detections=[], run_id="r-d", now=_now())
70
+
71
+ # Should tick to 3, still active
72
+ assert len(profile["entries"]) == 1
73
+ assert profile["entries"][0]["clean_streak_runs"] == 3
74
+ assert profile.get("graduated", []) == []
75
+
76
+
77
+ def test_low_confidence_retirement_archives_without_graduation_xp(_marker_cleanup):
78
+ """Confidence decay below RETIRE_BELOW is uncertainty, not mastery.
79
+ It leaves the live list but must not award +5 or fire graduation UX."""
80
+ profile = _blank_profile()
81
+ profile["entries"] = [{
82
+ "id": "w1", "name": "weakness 1", "tier": "active", "direction": "negative",
83
+ # One day of decay pushes this below RETIRE_BELOW, while clean streak
84
+ # remains far below the absence-graduation threshold.
85
+ "confidence": 0.31, "priority": 3, "nudge": "x", "examples": [],
86
+ "first_seen": "2026-04-19", "last_seen_in_run": "2026-04-19",
87
+ "clean_streak_runs": 0, "positive_run_streak": 0,
88
+ "source_runs": ["old"], "total_occurrences": 2,
89
+ }]
90
+
91
+ fragments = merge.merge(profile, detections=[], run_id="r-d", now=_now())
92
+
93
+ assert profile["entries"] == []
94
+ assert profile.get("graduated", []) == []
95
+ assert len(profile["archived"]) == 1
96
+ archived = profile["archived"][0]
97
+ assert archived["id"] == "w1"
98
+ assert archived["archive_reason"] == "low-confidence"
99
+ assert profile["graduation_xp"] == 0
100
+ assert any("archived:low-confidence" in f for f in fragments)
101
+ assert not merge.GRADUATION_MARKER.exists()
102
+
103
+
104
+ # --- mid-streak rewards ----------------------------------------------------
105
+
106
+ def test_strength_mid_streak_reward_fires(_marker_cleanup):
107
+ """The P1 #2 fix: positive entry re-detected → +1 XP banked at streak 2."""
108
+ profile = _blank_profile()
109
+ profile["entries"] = [{
110
+ "id": "s1", "name": "strength 1", "tier": "probationary", "direction": "positive",
111
+ "confidence": 0.6, "priority": 3, "nudge": "keep doing that", "examples": [],
112
+ "first_seen": "2026-04-13", "last_seen_in_run": "2026-04-13",
113
+ "positive_run_streak": 1, "clean_streak_runs": 0,
114
+ "source_runs": ["r-a"], "total_occurrences": 1,
115
+ }]
116
+ profile["recent_runs"] = ["r-a"]
117
+
118
+ merge.merge(
119
+ profile,
120
+ detections=[{"id": "s1", "name": "strength 1", "direction": "positive",
121
+ "priority": 3, "nudge": "keep doing that"}],
122
+ run_id="r-b", now=_now(),
123
+ )
124
+
125
+ # Streak bumped 1 → 2, milestone_xp += 1
126
+ assert profile["entries"][0]["positive_run_streak"] == 2
127
+ assert profile["milestone_xp"] == 1
128
+ assert profile["session_banked_xp"] == 0
129
+ # Marker written with direction: positive
130
+ marker_path = merge.STREAK_REWARD_MARKER
131
+ assert marker_path.exists()
132
+ data = json.loads(marker_path.read_text())
133
+ assert data["rewards"][0]["direction"] == "positive"
134
+ assert data["rewards"][0]["streak"] == 2
135
+ assert data["rewards"][0]["xp_awarded"] == 1
136
+
137
+
138
+ def test_strength_graduation_at_streak_5_no_double_reward(_marker_cleanup):
139
+ """Streak 4→5: graduates (+5) but NO mid-streak reward (5 not in schedule)."""
140
+ profile = _blank_profile()
141
+ profile["entries"] = [{
142
+ "id": "s1", "name": "strength 1", "tier": "active", "direction": "positive",
143
+ "confidence": 0.9, "priority": 3, "nudge": "", "examples": [],
144
+ "first_seen": "2026-03-01", "last_seen_in_run": "2026-04-14",
145
+ "positive_run_streak": 4, "clean_streak_runs": 0,
146
+ "source_runs": ["r-a", "r-b", "r-c"], "total_occurrences": 4,
147
+ }]
148
+ profile["recent_runs"] = ["r-a", "r-b", "r-c"]
149
+
150
+ merge.merge(
151
+ profile,
152
+ detections=[{"id": "s1", "name": "strength 1", "direction": "positive",
153
+ "priority": 3, "nudge": ""}],
154
+ run_id="r-d", now=_now(),
155
+ )
156
+
157
+ # Graduated mastery — no mid-streak marker
158
+ assert profile["entries"] == []
159
+ assert len(profile["graduated"]) == 1
160
+ assert profile["graduated"][0]["graduated_reason"] == "present-5-runs"
161
+ assert profile.get("milestone_xp", 0) == 0
162
+ assert profile.get("graduation_xp", 0) == 5
163
+ assert not merge.STREAK_REWARD_MARKER.exists()
164
+
165
+
166
+ def test_negative_mid_streak_reward_schedule(_marker_cleanup):
167
+ """+1/+1/+1/+2 across clean_streak ticks 1-4 for weaknesses."""
168
+ profile = _blank_profile()
169
+ profile["entries"] = [{
170
+ "id": "w1", "name": "weakness", "tier": "active", "direction": "negative",
171
+ "confidence": 0.8, "priority": 3, "nudge": "", "examples": [],
172
+ "first_seen": "2026-03-01", "last_seen_in_run": "2026-04-01",
173
+ "clean_streak_runs": 3, "positive_run_streak": 0, # will tick 3 → 4 → +2
174
+ "source_runs": ["old"], "total_occurrences": 10,
175
+ }]
176
+ merge.merge(profile, detections=[], run_id="r-d", now=_now())
177
+
178
+ assert profile["milestone_xp"] == 2 # streak hit 4 → +2
179
+ assert profile["session_banked_xp"] == 0
180
+ data = json.loads(merge.STREAK_REWARD_MARKER.read_text())
181
+ assert data["rewards"][0]["direction"] == "negative"
182
+ assert data["rewards"][0]["streak"] == 4
183
+ assert data["rewards"][0]["xp_awarded"] == 2
184
+
185
+
186
+ # --- reward_hint precedence through merge ----------------------------------
187
+
188
+ def test_explicit_reward_hint_preserved(_marker_cleanup):
189
+ """Detection with explicit reward_hint beats keyword inference."""
190
+ profile = _blank_profile()
191
+ detections = [{
192
+ "id": "edits-without-testing", # would infer test_run via keyword
193
+ "name": "edits without testing",
194
+ "direction": "negative",
195
+ "priority": 4,
196
+ "nudge": "skipped tests after edits",
197
+ "reward_hint": {"action": "commit", "xp": 1, "description": "custom"},
198
+ }]
199
+ merge.merge(profile, detections, run_id="r1", now=_now())
200
+
201
+ entry = profile["entries"][0]
202
+ assert entry["reward_hint"]["action"] == "commit"
203
+ assert entry["reward_hint"]["description"] == "custom"
204
+
205
+
206
+ def test_inference_backfills_when_no_explicit_hint(_marker_cleanup):
207
+ """Detection without reward_hint + nudge has keyword → inference fills it."""
208
+ profile = _blank_profile()
209
+ detections = [{
210
+ "id": "edits-without-testing",
211
+ "name": "edits without testing",
212
+ "direction": "negative",
213
+ "priority": 4,
214
+ "nudge": "skipped tests after edits",
215
+ }]
216
+ merge.merge(profile, detections, run_id="r1", now=_now())
217
+
218
+ entry = profile["entries"][0]
219
+ assert entry["reward_hint"] is not None
220
+ assert entry["reward_hint"]["action"] == "test_run"
221
+
222
+
223
+ # --- skills_by_project accumulator -----------------------------------------
224
+
225
+ def test_merge_skills_by_project_sums_across_projects():
226
+ existing = {"service": {"deploy-staging": 3, "design": 1}}
227
+ delta = {"service": {"deploy-staging": 2}, "widget": {"widget-build": 1}}
228
+ out = merge.merge_skills_by_project(existing, delta)
229
+ assert out["service"]["deploy-staging"] == 5
230
+ assert out["service"]["design"] == 1
231
+ assert out["widget"]["widget-build"] == 1
232
+
233
+
234
+ def test_merge_skills_by_project_returns_new_dict():
235
+ """Must not mutate caller's data — merge_skills_by_project is called
236
+ inside the locked profile-write path; aliasing the existing dict
237
+ would let a partial write leak across runs if anything failed
238
+ mid-process."""
239
+ existing = {"service": {"deploy-staging": 1}}
240
+ delta = {"service": {"deploy-staging": 1}}
241
+ out = merge.merge_skills_by_project(existing, delta)
242
+ assert existing["service"]["deploy-staging"] == 1 # untouched
243
+ assert out["service"]["deploy-staging"] == 2
244
+
245
+
246
+ def test_merge_skills_by_project_drops_garbage():
247
+ """Hostile shapes (non-dict skills, non-numeric counts) must be
248
+ silently filtered. The deterministic insights pass runs unattended
249
+ on cron; a single bad cell elsewhere in the profile shouldn't block
250
+ this merge."""
251
+ existing = {"service": {"deploy-staging": "not-a-number"}, "broken": "string"}
252
+ delta = {"widget": {"widget-build": 2}, 12345: {"x": 1}}
253
+ out = merge.merge_skills_by_project(existing, delta)
254
+ assert "broken" not in out
255
+ assert out["service"] == {} # bad value dropped
256
+ assert out["widget"]["widget-build"] == 2
257
+ # Non-string project keys get coerced rather than crashing.
258
+ assert "12345" in out
259
+
260
+
261
+ def test_merge_skills_by_project_handles_empty_inputs():
262
+ assert merge.merge_skills_by_project({}, {}) == {}
263
+ assert merge.merge_skills_by_project(None, None) == {}
264
+ assert merge.merge_skills_by_project({"a": {"x": 1}}, {}) == {"a": {"x": 1}}
265
+ assert merge.merge_skills_by_project({}, {"a": {"x": 1}}) == {"a": {"x": 1}}
266
+
267
+
268
+ # --- merge.main() end-to-end (argparse → flock → atomic write) -------------
269
+ #
270
+ # These exercise the full CLI wire-up that production /coach-insights uses, not
271
+ # just the merge_skills_by_project helper. Regression for review-finding #2
272
+ # (2026-04-24): a rename of the --skills-by-project-delta flag, or a
273
+ # forgotten persist after merge_skills_by_project, would pass every prior
274
+ # test in this file but break production. These guard the wire-up.
275
+
276
+
277
+ def _run_merge_main(monkeypatch, **paths):
278
+ """Invoke merge.main() with the given file paths via sys.argv,
279
+ matching how insights.sh invokes it. Returns merge.main()'s exit
280
+ code so tests can assert on success/failure."""
281
+ argv = ["merge.py", "--run-id", "r-test"]
282
+ for flag, p in paths.items():
283
+ argv.extend([f"--{flag.replace('_', '-')}", str(p)])
284
+ monkeypatch.setattr("sys.argv", argv)
285
+ return merge.main()
286
+
287
+
288
+ def test_main_persists_skills_by_project_delta_into_profile(
289
+ tmp_path, monkeypatch):
290
+ """Drive merge.main() end-to-end with a delta and verify the
291
+ rolling accumulator lands in the written profile. This is the
292
+ test the review-audit flagged as missing — without it, a refactor
293
+ that broke the persist step would still see all 53 prior tests
294
+ pass."""
295
+ profile_path = tmp_path / "profile.yaml"
296
+ yaml.safe_dump(_blank_profile(), profile_path.open("w"))
297
+
298
+ detections = tmp_path / "det.json"
299
+ detections.write_text("[]")
300
+
301
+ delta = tmp_path / "delta.json"
302
+ delta.write_text(json.dumps(
303
+ {"service": {"deploy-staging": 3}, "widget": {"design": 2}}))
304
+
305
+ rc = _run_merge_main(
306
+ monkeypatch,
307
+ profile=profile_path,
308
+ changelog=tmp_path / "changelog.md",
309
+ lock=tmp_path / ".lock",
310
+ detections=detections,
311
+ skills_by_project_delta=delta,
312
+ )
313
+ assert rc in (0, None) # main() returns None on success path
314
+
315
+ written = yaml.safe_load(profile_path.read_text())
316
+ assert written["skills_by_project"] == {
317
+ "service": {"deploy-staging": 3},
318
+ "widget": {"design": 2},
319
+ }
320
+
321
+
322
+ def test_main_accumulates_delta_into_existing_skills_by_project(
323
+ tmp_path, monkeypatch):
324
+ """A second run on top of an existing accumulator must SUM, not
325
+ replace. Locks the accumulator semantics at the CLI boundary."""
326
+ profile_path = tmp_path / "profile.yaml"
327
+ profile = _blank_profile()
328
+ profile["skills_by_project"] = {"service": {"deploy-staging": 5}}
329
+ yaml.safe_dump(profile, profile_path.open("w"))
330
+
331
+ detections = tmp_path / "det.json"
332
+ detections.write_text("[]")
333
+
334
+ delta = tmp_path / "delta.json"
335
+ delta.write_text(json.dumps({"service": {"deploy-staging": 2}}))
336
+
337
+ _run_merge_main(
338
+ monkeypatch,
339
+ profile=profile_path,
340
+ changelog=tmp_path / "changelog.md",
341
+ lock=tmp_path / ".lock",
342
+ detections=detections,
343
+ skills_by_project_delta=delta,
344
+ )
345
+
346
+ written = yaml.safe_load(profile_path.read_text())
347
+ assert written["skills_by_project"]["service"]["deploy-staging"] == 7
348
+
349
+
350
+ def test_main_preserves_skills_by_project_when_no_delta_passed(
351
+ tmp_path, monkeypatch):
352
+ """A run without --skills-by-project-delta must NOT clobber the
353
+ existing rolling counter. Guards a future refactor that defaults
354
+ the field to {} on every run (the `if sbp_delta:` guard at the
355
+ relevant line in main() would silently break otherwise)."""
356
+ profile_path = tmp_path / "profile.yaml"
357
+ profile = _blank_profile()
358
+ profile["skills_by_project"] = {"service": {"deploy-staging": 5}}
359
+ yaml.safe_dump(profile, profile_path.open("w"))
360
+
361
+ detections = tmp_path / "det.json"
362
+ detections.write_text("[]")
363
+
364
+ _run_merge_main(
365
+ monkeypatch,
366
+ profile=profile_path,
367
+ changelog=tmp_path / "changelog.md",
368
+ lock=tmp_path / ".lock",
369
+ detections=detections,
370
+ # NO skills_by_project_delta on purpose
371
+ )
372
+
373
+ written = yaml.safe_load(profile_path.read_text())
374
+ assert written["skills_by_project"] == {"service": {"deploy-staging": 5}}
375
+
376
+
377
+ def test_main_emits_changelog_fragment_for_new_pairs(
378
+ tmp_path, monkeypatch):
379
+ """Regression for the `+sbp:N` changelog fragment counting logic.
380
+ Currently it counts only NEW (project, skill) pairs, not increments
381
+ on existing ones. This test locks that behavior so a future change
382
+ has to be deliberate."""
383
+ profile_path = tmp_path / "profile.yaml"
384
+ profile = _blank_profile()
385
+ profile["skills_by_project"] = {"service": {"deploy-staging": 5}}
386
+ yaml.safe_dump(profile, profile_path.open("w"))
387
+
388
+ detections = tmp_path / "det.json"
389
+ detections.write_text("[]")
390
+
391
+ delta = tmp_path / "delta.json"
392
+ # One existing pair (deploy-staging@service, just incrementing) plus
393
+ # one new pair (widget-build@widget, never seen). Expect +sbp:1.
394
+ delta.write_text(json.dumps({
395
+ "service": {"deploy-staging": 1},
396
+ "widget": {"widget-build": 4},
397
+ }))
398
+
399
+ changelog = tmp_path / "changelog.md"
400
+ _run_merge_main(
401
+ monkeypatch,
402
+ profile=profile_path,
403
+ changelog=changelog,
404
+ lock=tmp_path / ".lock",
405
+ detections=detections,
406
+ skills_by_project_delta=delta,
407
+ )
408
+
409
+ line = changelog.read_text()
410
+ assert "+sbp:1" in line, (
411
+ f"expected `+sbp:1` for the one new (project,skill) pair; "
412
+ f"changelog was: {line!r}"
413
+ )
@@ -0,0 +1,90 @@
1
+ """Pin the BSD `mktemp` template gotcha.
2
+
3
+ `mktemp /tmp/foo-XXXXXX.json` on BSD (macOS default) creates a file named
4
+ literally `/tmp/foo-XXXXXX.json` — the `X`s are not replaced because BSD
5
+ mktemp won't substitute Xs that aren't at the end of the template. The
6
+ second run then silently fails because the file already exists. GNU
7
+ mktemp tolerates this. We had this bug in `skills/coach-insights/SKILL.md`
8
+ (then `skills/insights/SKILL.md`) pre-v0.2.0 and it broke the on-demand
9
+ analyzer flow for macOS users.
10
+
11
+ This test scans the bundle's own shell scripts and skill files for any
12
+ `mktemp` invocation whose template has a suffix after the `X`s. It is
13
+ careful to only scan files this bundle owns — running from
14
+ `~/.claude/coach/tests/` post-install, the broader `~/.claude/skills/`
15
+ directory contains skills from other plugins that this project does not
16
+ ship and should not police.
17
+
18
+ CLAUDE.md references the bad pattern in *prose* (to explain the
19
+ gotcha), so we intentionally don't scan pure prose docs.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from pathlib import Path
25
+
26
+ # Match `mktemp ` followed by anything, ending with `XXXXXX.<word>` BEFORE
27
+ # the closing whitespace, paren, or quote — i.e. an mktemp template that
28
+ # has a suffix after the X-block. This is what BSD won't substitute.
29
+ BROKEN_RE = re.compile(r"\bmktemp\s+[^\n)`'\"]*X{4,}\.[A-Za-z0-9]+")
30
+
31
+ # Skills the coach bundle owns — these are the only `skills/<name>/SKILL.md`
32
+ # files we should police. Other skills under `~/.claude/skills/` belong to
33
+ # unrelated plugins and may have their own conventions.
34
+ OWNED_SKILLS = ("coach-insights", "coach", "config")
35
+
36
+
37
+ def _scan_paths() -> list[Path]:
38
+ """Return absolute paths to scan, working in both layouts:
39
+
40
+ Bundle layout (running from repo checkout):
41
+ REPO_ROOT/coach/tests/test_no_broken_mktemp.py
42
+ REPO_ROOT/coach/bin/*.sh
43
+ REPO_ROOT/skills/{coach-insights,coach,config}/SKILL.md
44
+ REPO_ROOT/install.sh, install-launchd.sh
45
+
46
+ Install layout (running from ~/.claude/coach/tests/ after ./install.sh):
47
+ ~/.claude/coach/tests/test_no_broken_mktemp.py
48
+ ~/.claude/coach/bin/*.sh
49
+ ~/.claude/skills/{coach-insights,coach,config}/SKILL.md
50
+ (install.sh / install-launchd.sh do NOT exist post-install)
51
+ """
52
+ test_file = Path(__file__).resolve()
53
+ coach_root = test_file.parent.parent # the `coach/` directory
54
+ above = coach_root.parent # bundle root OR ~/.claude
55
+
56
+ paths: list[Path] = []
57
+ paths.extend(sorted(coach_root.glob("bin/*.sh")))
58
+ for name in ("install.sh", "install-launchd.sh"):
59
+ p = above / name
60
+ if p.is_file():
61
+ paths.append(p)
62
+ for skill in OWNED_SKILLS:
63
+ p = above / "skills" / skill / "SKILL.md"
64
+ if p.is_file():
65
+ paths.append(p)
66
+ return paths
67
+
68
+
69
+ def test_no_broken_mktemp_templates_in_scripts():
70
+ paths = _scan_paths()
71
+ assert paths, (
72
+ "scan resolved zero candidate paths — fixture broken; "
73
+ "expected at least coach/bin/*.sh"
74
+ )
75
+ offenders: list[str] = []
76
+ for path in paths:
77
+ try:
78
+ text = path.read_text(encoding="utf-8")
79
+ except Exception:
80
+ continue
81
+ for lineno, line in enumerate(text.splitlines(), start=1):
82
+ if BROKEN_RE.search(line):
83
+ offenders.append(f"{path}:{lineno}: {line.strip()}")
84
+ assert not offenders, (
85
+ "Found `mktemp` template(s) with a suffix after the X-block.\n"
86
+ "On BSD/macOS this creates a literal `XXXXXX...` filename and "
87
+ "the second run silently fails. Move the suffix off the template "
88
+ "(callers don't rely on the extension).\n\n"
89
+ + "\n".join(offenders)
90
+ )
@@ -0,0 +1,137 @@
1
+ """render_env.py — terminal vs IDE detection from CLAUDE_CODE_ENTRYPOINT."""
2
+ from __future__ import annotations
3
+
4
+ from render_env import detect_render_env
5
+
6
+
7
+ # -----------------------------------------------------------------------------
8
+ # Defaults / unset
9
+ # -----------------------------------------------------------------------------
10
+
11
+ def test_empty_env_is_terminal():
12
+ assert detect_render_env({}) == "terminal"
13
+
14
+
15
+ def test_unset_entrypoint_is_terminal():
16
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": ""}) == "terminal"
17
+
18
+
19
+ # -----------------------------------------------------------------------------
20
+ # Known terminal entrypoints
21
+ # -----------------------------------------------------------------------------
22
+
23
+ def test_cli_is_terminal():
24
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "cli"}) == "terminal"
25
+
26
+
27
+ def test_mcp_is_terminal():
28
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "mcp"}) == "terminal"
29
+
30
+
31
+ def test_sdk_py_is_terminal():
32
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "sdk-py"}) == "terminal"
33
+
34
+
35
+ def test_sdk_ts_is_terminal():
36
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "sdk-ts"}) == "terminal"
37
+
38
+
39
+ # -----------------------------------------------------------------------------
40
+ # Known IDE entrypoints
41
+ # -----------------------------------------------------------------------------
42
+
43
+ def test_vscode_is_ide():
44
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "vscode"}) == "ide"
45
+
46
+
47
+ def test_claude_vscode_is_ide():
48
+ """Cursor's Claude Code integration reports this value (verified 2026-05)."""
49
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "claude-vscode"}) == "ide"
50
+
51
+
52
+ def test_jetbrains_is_ide():
53
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "jetbrains"}) == "ide"
54
+
55
+
56
+ def test_claude_jetbrains_is_ide():
57
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "claude-jetbrains"}) == "ide"
58
+
59
+
60
+ def test_ide_onboarding_is_ide():
61
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "ide-onboarding"}) == "ide"
62
+
63
+
64
+ # -----------------------------------------------------------------------------
65
+ # Normalization
66
+ # -----------------------------------------------------------------------------
67
+
68
+ def test_entrypoint_is_case_insensitive():
69
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "VSCODE"}) == "ide"
70
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "Claude-VSCode"}) == "ide"
71
+
72
+
73
+ def test_entrypoint_whitespace_is_stripped():
74
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": " vscode "}) == "ide"
75
+
76
+
77
+ # -----------------------------------------------------------------------------
78
+ # Allowlist semantics — unknown values default to terminal
79
+ # -----------------------------------------------------------------------------
80
+
81
+ def test_unknown_entrypoint_defaults_to_terminal():
82
+ """Future / unrecognized entrypoints fall through to the safe default.
83
+ Terminal shape uses universal markdown and renders acceptably everywhere."""
84
+ assert detect_render_env({"CLAUDE_CODE_ENTRYPOINT": "some-future-surface"}) == "terminal"
85
+
86
+
87
+ # -----------------------------------------------------------------------------
88
+ # COACH_RENDER_ENV override
89
+ # -----------------------------------------------------------------------------
90
+
91
+ def test_override_forces_ide_over_terminal_entrypoint():
92
+ assert detect_render_env({
93
+ "CLAUDE_CODE_ENTRYPOINT": "cli",
94
+ "COACH_RENDER_ENV": "ide",
95
+ }) == "ide"
96
+
97
+
98
+ def test_override_forces_terminal_over_ide_entrypoint():
99
+ assert detect_render_env({
100
+ "CLAUDE_CODE_ENTRYPOINT": "vscode",
101
+ "COACH_RENDER_ENV": "terminal",
102
+ }) == "terminal"
103
+
104
+
105
+ def test_override_is_case_insensitive():
106
+ assert detect_render_env({
107
+ "CLAUDE_CODE_ENTRYPOINT": "cli",
108
+ "COACH_RENDER_ENV": "IDE",
109
+ }) == "ide"
110
+
111
+
112
+ def test_invalid_override_falls_back_to_entrypoint_detection():
113
+ """Garbage override is ignored; entrypoint detection still runs."""
114
+ assert detect_render_env({
115
+ "CLAUDE_CODE_ENTRYPOINT": "vscode",
116
+ "COACH_RENDER_ENV": "bogus",
117
+ }) == "ide"
118
+ assert detect_render_env({
119
+ "CLAUDE_CODE_ENTRYPOINT": "cli",
120
+ "COACH_RENDER_ENV": "bogus",
121
+ }) == "terminal"
122
+
123
+
124
+ # -----------------------------------------------------------------------------
125
+ # Default: uses os.environ when no env arg provided
126
+ # -----------------------------------------------------------------------------
127
+
128
+ def test_uses_os_environ_when_env_arg_omitted(monkeypatch):
129
+ monkeypatch.setenv("CLAUDE_CODE_ENTRYPOINT", "vscode")
130
+ monkeypatch.delenv("COACH_RENDER_ENV", raising=False)
131
+ assert detect_render_env() == "ide"
132
+
133
+
134
+ def test_uses_os_environ_default_terminal(monkeypatch):
135
+ monkeypatch.delenv("CLAUDE_CODE_ENTRYPOINT", raising=False)
136
+ monkeypatch.delenv("COACH_RENDER_ENV", raising=False)
137
+ assert detect_render_env() == "terminal"