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