@rm0nroe/coach-claw 1.0.6 → 1.0.8

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.
@@ -292,7 +292,7 @@ def aggregate(sessions: list[dict]) -> tuple[list[dict], dict]:
292
292
  if len(under_plan) >= 3:
293
293
  detections.append({
294
294
  "id": "under-planning",
295
- "name": "under-planning",
295
+ "name": "thin planning",
296
296
  "nudge": (
297
297
  f"Across {len(under_plan)} of {n} recent sessions, editing "
298
298
  "started within 2 minutes of the first user turn with no "
@@ -225,6 +225,37 @@ SPECS: dict[str, dict] = {
225
225
  }
226
226
 
227
227
 
228
+ # -----------------------------------------------------------------------------
229
+ # Per-theme labels for the tip-complete ack banners (B9). Every theme gets
230
+ # its own pair — `tip_cleared` (weakness completion ack) and
231
+ # `strength_reinforced` (strength completion ack). Default theme `craft`
232
+ # keeps the original wording so existing tests and behavior don't shift.
233
+ # Picked to match each theme's voice; 2-word phrases for visual parity with
234
+ # the originals; no emoji prefix so the leading ✅/💪 stays the visual
235
+ # signature.
236
+ COMPLETION_LABELS: dict[str, dict[str, str]] = {
237
+ "craft": {"tip_cleared": "Tip cleared", "strength_reinforced": "Strength reinforced"},
238
+ "forge": {"tip_cleared": "Iron struck", "strength_reinforced": "Edge sharpened"},
239
+ "cosmic": {"tip_cleared": "Course corrected", "strength_reinforced": "Constellation drawn"},
240
+ "ocean": {"tip_cleared": "Wave caught", "strength_reinforced": "Tide carries"},
241
+ "skyrim": {"tip_cleared": "Quest cleared", "strength_reinforced": "Skill mastered"},
242
+ "marvel": {"tip_cleared": "Threat neutralized", "strength_reinforced": "Power harnessed"},
243
+ "dc": {"tip_cleared": "Watch kept", "strength_reinforced": "Beacon answered"},
244
+ "finalfantasy": {"tip_cleared": "Encounter cleared", "strength_reinforced": "Stat boosted"},
245
+ "military": {"tip_cleared": "Mission accomplished","strength_reinforced": "Drill burned in"},
246
+ "lotr": {"tip_cleared": "Burden lightened", "strength_reinforced": "Heart steadfast"},
247
+ "starwars": {"tip_cleared": "Order kept", "strength_reinforced": "Force grows"},
248
+ "hacker": {"tip_cleared": "Exploit landed", "strength_reinforced": "Pattern indexed"},
249
+ }
250
+
251
+
252
+ def completion_labels(theme: str) -> dict[str, str]:
253
+ """Return per-theme labels for the tip-complete ack banners. Falls
254
+ back to the `craft` defaults for unknown themes so future theme
255
+ additions don't crash banner rendering."""
256
+ return COMPLETION_LABELS.get(theme) or COMPLETION_LABELS["craft"]
257
+
258
+
228
259
  def _resolve_spec(theme: str, dual_blade_supported: bool) -> dict:
229
260
  """Apply glyph fallbacks to the spec for the active theme + terminal.
230
261
  Returns a plain dict — callers must not mutate the SPECS source."""
@@ -0,0 +1,57 @@
1
+ """Slug → user-facing display name mapping.
2
+
3
+ Single source of truth used by every banner, ack, and /coach status row.
4
+ Resolution order:
5
+ 1. WORDING_OVERRIDES — curated wording for known patterns
6
+ 2. profile["entries"|"graduated"|"archived"|"strengths"] entry.name
7
+ (skipped when name == id, defending against the analyze.py:295
8
+ class of bug where slug accidentally lands in the name field)
9
+ 3. Humanized slug — `entry_id.replace("-", " ")`
10
+ 4. The entry_id itself (defensive — empty string stays empty)
11
+ """
12
+ from __future__ import annotations
13
+
14
+
15
+ WORDING_OVERRIDES: dict[str, str] = {
16
+ # Weakness patterns
17
+ "edits-without-testing": "edits without testing",
18
+ "commit-without-testing": "committing without testing",
19
+ "under-planning": "thin planning",
20
+ "skipped-search-tools": "skipping search tools",
21
+ "exploration-without-landing": "exploration without landing",
22
+ "heavy-agent-delegation": "heavy subagent delegation",
23
+ "heavy-subagent-delegation": "heavy subagent delegation",
24
+ "buggy-code": "buggy code",
25
+ "wrong-approach": "wrong approach",
26
+ # Strength patterns
27
+ "tests-after-edits": "testing after edits",
28
+ "safe-git-hygiene": "safe git hygiene",
29
+ "effective-skill-use": "effective skill use",
30
+ }
31
+
32
+ _PROFILE_BUCKETS = ("entries", "graduated", "archived", "strengths")
33
+
34
+
35
+ def display_name(entry_id: str, profile: dict | None = None) -> str:
36
+ """Resolve `entry_id` to a user-facing display name.
37
+
38
+ The `name == id` guard catches the analyze.py bug class where a
39
+ detection accidentally writes its slug into the `name` field; in
40
+ that case we fall through to humanized-slug rendering rather than
41
+ leaking the kebab-case form.
42
+ """
43
+ if not entry_id:
44
+ return entry_id
45
+ if entry_id in WORDING_OVERRIDES:
46
+ return WORDING_OVERRIDES[entry_id]
47
+ if isinstance(profile, dict):
48
+ for bucket in _PROFILE_BUCKETS:
49
+ for entry in profile.get(bucket) or []:
50
+ if not isinstance(entry, dict):
51
+ continue
52
+ if entry.get("id") == entry_id:
53
+ name = entry.get("name")
54
+ if name and name != entry_id:
55
+ return name
56
+ break
57
+ return entry_id.replace("-", " ")
@@ -36,6 +36,7 @@ PROJECTS = Path.home() / ".claude" / "projects"
36
36
  from stats import LEVELS as _STATS_LEVELS # type: ignore # noqa: E402
37
37
  from scoring import score_transcript_with_breakdown # type: ignore # noqa: E402
38
38
  from xp_accounting import normalize_profile_xp # type: ignore # noqa: E402
39
+ from display_names import display_name # type: ignore # noqa: E402
39
40
  LEVELS = _STATS_LEVELS
40
41
 
41
42
  SESSION_XP_CAP = 15
@@ -100,13 +101,19 @@ def _bar(filled: int, total: int) -> str:
100
101
  return CYAN + "[" + ("▰" * filled) + RESET + GREY + ("▱" * (total - filled)) + RESET + CYAN + "]" + RESET
101
102
 
102
103
 
103
- def _streak_bar(streak: int, target: int = GRADUATION_STREAK_TARGET) -> str:
104
- """🔴🔴🔴⚪⚪ bar — red fill for earned positions, hollow white for
105
- remaining. Matches coach-user-prompt.py:_streak_bar so /coach status
106
- and the in-chat tip share the same glyph + color story without
107
- needing ANSI in either surface."""
104
+ def _streak_bar(
105
+ streak: int,
106
+ target: int = GRADUATION_STREAK_TARGET,
107
+ *,
108
+ fill_glyph: str = "🟢",
109
+ empty_glyph: str = "⚪",
110
+ ) -> str:
111
+ """🟢🟢🟢⚪⚪ bar for /coach status rows — green fill = baseline
112
+ progress display. Mirrors coach-user-prompt.py:_streak_bar's signature
113
+ (kwargs for color override) but defaults to green here since /coach
114
+ status only ever renders the baseline (non-attribution) shape."""
108
115
  streak = max(0, min(streak, target))
109
- return "🔴" * streak + "⚪" * (target - streak)
116
+ return fill_glyph * streak + empty_glyph * (target - streak)
110
117
 
111
118
 
112
119
  def main() -> int:
@@ -239,12 +246,13 @@ def main() -> int:
239
246
  )
240
247
  for e in weaknesses_sorted[:5]:
241
248
  eid = e.get("id", "?")
249
+ display = display_name(eid, profile) if eid != "?" else eid
242
250
  streak = int(e.get("clean_streak_runs", 0) or 0)
243
251
  tier = e.get("tier", "?")
244
252
  bar = _streak_bar(streak)
245
253
  label = "graduates" if streak >= GRADUATION_STREAK_TARGET else "to graduation"
246
254
  print(
247
- f" {GREY}·{RESET} {eid} {bar} {CYAN}{streak}/{GRADUATION_STREAK_TARGET}{RESET} "
255
+ f" {GREY}·{RESET} {display} {bar} {CYAN}{streak}/{GRADUATION_STREAK_TARGET}{RESET} "
248
256
  f"{GREY}({tier} · {label}){RESET}"
249
257
  )
250
258
  if len(weaknesses) > 5:
@@ -265,12 +273,13 @@ def main() -> int:
265
273
  )
266
274
  for e in strengths_sorted[:5]:
267
275
  eid = e.get("id", "?")
276
+ display = display_name(eid, profile) if eid != "?" else eid
268
277
  streak = int(e.get("positive_run_streak", 0) or 0)
269
278
  tier = e.get("tier", "?")
270
279
  bar = _streak_bar(streak)
271
280
  label = "masters" if streak >= GRADUATION_STREAK_TARGET else "to mastery"
272
281
  print(
273
- f" {GREY}·{RESET} {eid} {bar} {CYAN}{streak}/{GRADUATION_STREAK_TARGET}{RESET} "
282
+ f" {GREY}·{RESET} {display} {bar} {CYAN}{streak}/{GRADUATION_STREAK_TARGET}{RESET} "
274
283
  f"{GREY}({tier} · {label}){RESET}"
275
284
  )
276
285
  if len(strengths) > 5:
@@ -18,6 +18,8 @@ import banner_themes
18
18
  import stats
19
19
  from banner_themes import (
20
20
  BESPOKE_THEMES,
21
+ COMPLETION_LABELS,
22
+ completion_labels,
21
23
  render_celebrate_for_theme,
22
24
  _render_verb_style,
23
25
  _format_window_phrase,
@@ -237,7 +239,7 @@ def test_ocean_returns_none_when_nothing_to_render():
237
239
  def test_ocean_grads_render_between_streak_and_levelup():
238
240
  """Pre-rendered graduation block lands BETWEEN the bespoke streak
239
241
  section and the bespoke levelup footer — the order locked in the plan."""
240
- grads_default = "> 🎓⚡️ **GRADUATED: skipped search tools** `+5 XP`\n> `🔴🔴🔴🔴🔴` — 5 clean Coach insights runs in a row — weakness retired."
242
+ grads_default = "> 🎓⚡️ **GRADUATED: skipped search tools** `+5 XP`\n> `🟡🟡🟡🟡🟡` — 5 clean Coach insights runs in a row — weakness retired."
241
243
  out = render_celebrate_for_theme(
242
244
  "ocean",
243
245
  streak_rewards=_ocean_streak_fixture(),
@@ -979,3 +981,45 @@ def test_hacker_l50_uses_root_access_line():
979
981
  assert out is not None
980
982
  assert "root access 🔓 max layer reached" in out
981
983
  assert "next breach 🔓 0" not in out
984
+
985
+
986
+ # -----------------------------------------------------------------------------
987
+ # Per-theme completion labels (B9 ack banners): each of the 12 themes
988
+ # carries its own pair (`tip_cleared`, `strength_reinforced`). Default
989
+ # `craft` keeps the original wording so existing fixtures don't shift.
990
+ # -----------------------------------------------------------------------------
991
+
992
+ def test_completion_labels_all_twelve_themes_present():
993
+ """Every theme name registered in stats has a label entry, so the
994
+ /config theme picker can never land on a theme without an ack banner."""
995
+ expected = {
996
+ "craft", "forge", "cosmic", "ocean", "skyrim", "marvel",
997
+ "dc", "finalfantasy", "military", "lotr", "starwars", "hacker",
998
+ }
999
+ assert set(COMPLETION_LABELS.keys()) == expected
1000
+ for theme, labels in COMPLETION_LABELS.items():
1001
+ assert "tip_cleared" in labels, f"{theme} missing tip_cleared"
1002
+ assert "strength_reinforced" in labels, f"{theme} missing strength_reinforced"
1003
+
1004
+
1005
+ def test_completion_labels_craft_is_default_wording():
1006
+ """craft preserves the original strings — tests pinning literal
1007
+ 'Tip cleared' / 'Strength reinforced' must keep passing."""
1008
+ assert completion_labels("craft") == {
1009
+ "tip_cleared": "Tip cleared",
1010
+ "strength_reinforced": "Strength reinforced",
1011
+ }
1012
+
1013
+
1014
+ def test_completion_labels_themed_examples():
1015
+ """Spot check three distinct themes to lock in the proposed wording."""
1016
+ assert completion_labels("military")["tip_cleared"] == "Mission accomplished"
1017
+ assert completion_labels("hacker")["tip_cleared"] == "Exploit landed"
1018
+ assert completion_labels("ocean")["strength_reinforced"] == "Tide carries"
1019
+
1020
+
1021
+ def test_completion_labels_unknown_theme_falls_back_to_craft():
1022
+ """Unknown / future theme names return the craft defaults so banner
1023
+ rendering never crashes on a missing key."""
1024
+ assert completion_labels("not-a-real-theme") == COMPLETION_LABELS["craft"]
1025
+ assert completion_labels("") == COMPLETION_LABELS["craft"]
@@ -383,12 +383,16 @@ def test_celebrate_block_includes_verbatim_instruction(cup, now):
383
383
  def test_celebrate_combines_all_event_kinds(cup, now):
384
384
  """Regression + streak + graduation + level-up — verify ordering and
385
385
  that all four sections are present without bleeding into each other."""
386
+ # Use slug-form ids whose humanized fallback (`-` → space) matches
387
+ # the asserted display text. display_name() is now the single
388
+ # source of truth — marker `name` is ignored, so synthetic ids
389
+ # without an override render via humanized-slug fallback.
386
390
  block = cup._assemble_celebrate_block(
387
- grads=[{"id": "g1", "name": "pattern g", "direction": "positive",
391
+ grads=[{"id": "pattern-g", "name": "pattern g", "direction": "positive",
388
392
  "graduated_reason": "present-5-runs"}],
389
- regs=[{"id": "r1", "name": "pattern r",
393
+ regs=[{"id": "pattern-r", "name": "pattern r",
390
394
  "originally_graduated_at": "2026-04-01"}],
391
- streak_rewards=[{"id": "s1", "name": "pattern s",
395
+ streak_rewards=[{"id": "pattern-s", "name": "pattern s",
392
396
  "direction": "negative", "streak": 2, "target": 5,
393
397
  "xp_awarded": 1}],
394
398
  levelup={"from": "L3 X", "to": "Y", "to_idx": 3, "xp_at_levelup": 100},
@@ -0,0 +1,104 @@
1
+ """Slug → display name resolution.
2
+
3
+ Pins the curated overrides, profile-name lookup, name-equals-id guard,
4
+ and humanized-slug fallback. The helper is the single source of truth
5
+ for every user-facing surface that mentions a pattern by name.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from display_names import WORDING_OVERRIDES, display_name
12
+
13
+
14
+ # -----------------------------------------------------------------------------
15
+ # Curated overrides — all 12 slugs round-trip
16
+ # -----------------------------------------------------------------------------
17
+
18
+ @pytest.mark.parametrize(("entry_id", "expected"), list(WORDING_OVERRIDES.items()))
19
+ def test_curated_override_takes_precedence(entry_id, expected):
20
+ assert display_name(entry_id) == expected
21
+
22
+
23
+ def test_curated_override_wins_over_profile_name():
24
+ """Override table is the highest-priority source — even a profile
25
+ entry with a populated `name` doesn't override the curated phrase."""
26
+ profile = {"entries": [{"id": "under-planning", "name": "something else"}]}
27
+ assert display_name("under-planning", profile) == "thin planning"
28
+
29
+
30
+ # -----------------------------------------------------------------------------
31
+ # Profile-name lookup (used when no override exists)
32
+ # -----------------------------------------------------------------------------
33
+
34
+ def test_profile_name_used_when_no_override():
35
+ profile = {
36
+ "entries": [{"id": "novel-pattern", "name": "novel pattern detected"}],
37
+ }
38
+ assert display_name("novel-pattern", profile) == "novel pattern detected"
39
+
40
+
41
+ def test_profile_lookup_searches_all_buckets():
42
+ """name lookup must search graduated/archived/strengths too — entries
43
+ can move between buckets across runs."""
44
+ grad = {"graduated": [{"id": "x-y", "name": "X to Y"}]}
45
+ arc = {"archived": [{"id": "x-y", "name": "X to Y"}]}
46
+ st = {"strengths": [{"id": "x-y", "name": "X to Y"}]}
47
+ for profile in (grad, arc, st):
48
+ assert display_name("x-y", profile) == "X to Y"
49
+
50
+
51
+ # -----------------------------------------------------------------------------
52
+ # `name == id` guard — defends against analyze.py:295 bug class
53
+ # -----------------------------------------------------------------------------
54
+
55
+ def test_name_equals_id_guard_falls_through_to_humanized():
56
+ """If a detection wrote slug into name (the analyze.py:295 bug),
57
+ we ignore it and render the humanized form."""
58
+ profile = {"entries": [{"id": "broken-entry", "name": "broken-entry"}]}
59
+ assert display_name("broken-entry", profile) == "broken entry"
60
+
61
+
62
+ # -----------------------------------------------------------------------------
63
+ # Humanized slug fallback
64
+ # -----------------------------------------------------------------------------
65
+
66
+ def test_humanized_slug_fallback_no_profile():
67
+ assert display_name("my-new-pattern") == "my new pattern"
68
+
69
+
70
+ def test_humanized_slug_fallback_no_match_in_profile():
71
+ profile = {"entries": [{"id": "different-thing", "name": "different thing"}]}
72
+ assert display_name("my-new-pattern", profile) == "my new pattern"
73
+
74
+
75
+ def test_single_word_id_passes_through_unchanged():
76
+ assert display_name("debug") == "debug"
77
+
78
+
79
+ # -----------------------------------------------------------------------------
80
+ # Defensive cases
81
+ # -----------------------------------------------------------------------------
82
+
83
+ def test_empty_id_returns_empty():
84
+ assert display_name("") == ""
85
+
86
+
87
+ def test_none_profile_handled():
88
+ assert display_name("under-planning", None) == "thin planning"
89
+ assert display_name("novel-thing", None) == "novel thing"
90
+
91
+
92
+ def test_malformed_profile_handled():
93
+ """Malformed bucket entries (non-dicts, missing keys) don't crash."""
94
+ profile = {
95
+ "entries": [None, "string-entry", {"no_id": "field"}, {"id": None}],
96
+ "graduated": "not-a-list",
97
+ }
98
+ assert display_name("missing", profile) == "missing"
99
+ assert display_name("missing-thing", profile) == "missing thing"
100
+
101
+
102
+ def test_profile_entry_with_no_name_falls_through():
103
+ profile = {"entries": [{"id": "id-only"}]}
104
+ assert display_name("id-only", profile) == "id only"
@@ -110,7 +110,7 @@ def _streak_fixture():
110
110
  # starwars MUST render the historical default shape. No bespoke dispatch.
111
111
 
112
112
  def test_craft_theme_terminal_renders_default_shape(cup):
113
- """craft + terminal must produce the default `> ↑ name 🔴🔴🔴🔴⚪ ...`
113
+ """craft + terminal must produce the default `> ↑ name 🟢🟢🟢🟢⚪ ...`
114
114
  shape — byte-for-byte regression guard for the seven untouched themes."""
115
115
  block = cup._assemble_celebrate_block(
116
116
  grads=[],
@@ -124,10 +124,10 @@ def test_craft_theme_terminal_renders_default_shape(cup):
124
124
  streak_oldest=YESTERDAY,
125
125
  )
126
126
  assert block is not None
127
- # Default streak rendering uses 🔴⚪ meter and inline backtick spans.
127
+ # Default streak rendering uses 🟢⚪ meter and inline backtick spans.
128
128
  # If a regression flips this back to a bespoke shape, this test fails.
129
- assert "> ↑ `safe git hygiene` `🔴🔴🔴🔴⚪` 4/5 · `+2`" in block
130
- assert "> ↓ `heavy subagent delegation` `🔴🔴🔴🔴⚪` 4/5 · `-2`" in block
129
+ assert "> ↑ `safe git hygiene` `🟢🟢🟢🟢⚪` 4/5 · `+2`" in block
130
+ assert "> ↓ `heavy subagent delegation` `🟢🟢🟢🟢⚪` 4/5 · `-2`" in block
131
131
  # Bespoke header / glyphs MUST NOT appear.
132
132
  assert "Tide turned" not in block
133
133
  assert "🦞" not in block
@@ -175,6 +175,50 @@ def test_ocean_theme_terminal_uses_bespoke_render(cup):
175
175
  assert "Milestones earned across earlier sessions" not in block
176
176
 
177
177
 
178
+ def test_bespoke_theme_normalizes_streak_reward_names(cup):
179
+ """Regression: bespoke themes (ocean/forge/skyrim/military/hacker)
180
+ used to render `r["name"]` raw, leaking slugs whenever the marker
181
+ payload had `name == id` (the analyze.py:295 bug class). The Pass C
182
+ normalization in _assemble_celebrate_block now resolves names via
183
+ display_name BEFORE the bespoke dispatch, so all five bespoke themes
184
+ inherit the same humanized rendering as the default-theme path."""
185
+ bad_marker = [
186
+ {"id": "under-planning", "name": "under-planning", # slug-as-name
187
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "negative"},
188
+ ]
189
+ # hacker theme snake-cases names by design ("thin planning" →
190
+ # "thin_planning"); other bespoke themes keep the space form.
191
+ expected_form = {
192
+ "ocean": "thin planning",
193
+ "forge": "thin planning",
194
+ "skyrim": "thin planning",
195
+ "military": "thin planning",
196
+ "hacker": "thin_planning",
197
+ }
198
+ for theme, expected in expected_form.items():
199
+ block = cup._assemble_celebrate_block(
200
+ grads=[],
201
+ regs=[],
202
+ streak_rewards=bad_marker,
203
+ levelup=None,
204
+ caught_up=False,
205
+ env="terminal",
206
+ theme=theme,
207
+ now=NOW,
208
+ streak_oldest=YESTERDAY,
209
+ )
210
+ assert block is not None, f"{theme} produced no block"
211
+ assert "under-planning" not in block, (
212
+ f"{theme} leaked the kebab slug — Pass C normalization regressed"
213
+ )
214
+ assert "under_planning" not in block, (
215
+ f"{theme} leaked the snake-cased slug — name field wasn't normalized"
216
+ )
217
+ assert expected in block, (
218
+ f"{theme} did not render the curated override (expected {expected!r})"
219
+ )
220
+
221
+
178
222
  def test_ocean_theme_ide_falls_back_to_default(cup):
179
223
  """IDE rendering is terminal-only for bespoke themes. ocean + ide must
180
224
  produce the default HR-framed shape, not bespoke ASCII frames."""
@@ -841,7 +841,7 @@ def test_xp_attribution_uses_arrow_marker(cup):
841
841
  assert weakness_lines[0].startswith("_↑ +2 per test run")
842
842
  assert "XP" not in weakness_lines[0]
843
843
  assert "🔥" not in weakness_lines[0]
844
- assert weakness_lines[1].startswith("_🌡️ Warming up")
844
+ assert weakness_lines[1].startswith("_♨️ Let 'em cook")
845
845
  assert "2/5" in weakness_lines[1]
846
846
  assert all("✨" not in line for line in weakness_lines)
847
847
 
@@ -849,7 +849,9 @@ def test_xp_attribution_uses_arrow_marker(cup):
849
849
  @pytest.mark.parametrize(
850
850
  ("streak", "expected"),
851
851
  [
852
- (2, "_🌡️ Warming up 🔴🔴⚪⚪⚪ 2/5 → +5 bonus at 5/5._"),
852
+ (0, "_🧊 Ice cold ⚪⚪⚪⚪⚪ 0/5 → +5 bonus at 5/5._"),
853
+ (1, "_🌡️ Warming up 🔴⚪⚪⚪⚪ 1/5 → +5 bonus at 5/5._"),
854
+ (2, "_♨️ Let 'em cook 🔴🔴⚪⚪⚪ 2/5 → +5 bonus at 5/5._"),
853
855
  (3, "_🌶️ Heating up 🔴🔴🔴⚪⚪ 3/5 → +5 bonus at 5/5._"),
854
856
  (4, "_🔥 Streak 🔴🔴🔴🔴⚪ 4/5 → +5 bonus at 5/5._"),
855
857
  (5, "_🏆 Mastered 🔴🔴🔴🔴🔴 5/5 → +5 bonus ready._"),
@@ -863,7 +865,9 @@ def test_weakness_streak_stage_ladder(cup, streak, expected):
863
865
  @pytest.mark.parametrize(
864
866
  ("streak", "expected"),
865
867
  [
866
- (2, "_🌡️ Warming up 🔴🔴⚪⚪⚪ 2/5 → +5 mastery bonus at 5/5._"),
868
+ (0, "_🧊 Ice cold ⚪⚪⚪⚪⚪ 0/5 → +5 mastery bonus at 5/5._"),
869
+ (1, "_🌡️ Warming up 🔴⚪⚪⚪⚪ 1/5 → +5 mastery bonus at 5/5._"),
870
+ (2, "_♨️ Let 'em cook 🔴🔴⚪⚪⚪ 2/5 → +5 mastery bonus at 5/5._"),
867
871
  (3, "_🌶️ Heating up 🔴🔴🔴⚪⚪ 3/5 → +5 mastery bonus at 5/5._"),
868
872
  (4, "_🔥 Streak 🔴🔴🔴🔴⚪ 4/5 → +5 mastery bonus at 5/5._"),
869
873
  (5, "_🏆 Mastered 🔴🔴🔴🔴🔴 5/5 → +5 mastery bonus ready._"),
@@ -910,7 +914,8 @@ def test_strength_completion_banner_reinforces_instead_of_clearing(cup):
910
914
  })
911
915
  ])
912
916
  assert "> 💪 Strength reinforced — test runner detected" in block
913
- assert "> +2 XP · tests-after-edits strength streak 🔴🔴⚪⚪⚪" in block
917
+ assert "> +2 XP · testing after edits strength streak 🟢🟢⚪⚪⚪" in block
918
+ assert "tests-after-edits" not in block # slug must not leak
914
919
  assert "advances on next /coach-insights run" not in block
915
920
 
916
921
 
@@ -146,9 +146,10 @@ def test_regression_terminal_uses_blockquote(cup):
146
146
  "originally_graduated_at": "2026-04-01"}],
147
147
  env="terminal",
148
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
149
+ # Verbatim banner: display_name override wins over marker name, so
150
+ # the heading shows the curated wording. Graduation date is
151
+ # interpolated into the body sentence.
152
+ assert "> ⚠️ **Regressed: edits without testing**" in block
152
153
  assert "(was graduated 2026-04-01)" in block
153
154
  assert "edits-without-testing" not in block # slug must not leak
154
155
  assert "---" not in block
@@ -160,7 +161,7 @@ def test_regression_ide_uses_hr_frame(cup):
160
161
  "originally_graduated_at": "2026-04-01"}],
161
162
  env="ide",
162
163
  )
163
- assert "⚠️ **Regressed** — `Edits without testing`" in block
164
+ assert "⚠️ **Regressed** — `edits without testing`" in block
164
165
  assert "edits-without-testing" not in block # slug must not leak
165
166
  assert block.startswith("---\n")
166
167
  assert block.endswith("\n---")
@@ -178,7 +179,7 @@ def test_streak_reward_terminal_negative(cup):
178
179
  "direction": "negative", "streak": 3, "target": 5, "xp_awarded": 1}],
179
180
  env="terminal",
180
181
  )
181
- expected = "> ↓ `edits without testing` `🔴🔴🔴⚪⚪` 3/5 · `-1`"
182
+ expected = "> ↓ `edits without testing` `🟢🟢🟢⚪⚪` 3/5 · `-1`"
182
183
  assert expected in block
183
184
  assert "↑" not in block
184
185
  assert "edits-without-testing" not in block # slug must not leak
@@ -191,7 +192,7 @@ def test_streak_reward_terminal_positive(cup):
191
192
  "direction": "positive", "streak": 4, "target": 5, "xp_awarded": 2}],
192
193
  env="terminal",
193
194
  )
194
- expected = "> ↑ `safe git hygiene` `🔴🔴🔴🔴⚪` 4/5 · `+2`"
195
+ expected = "> ↑ `safe git hygiene` `🟢🟢🟢🟢⚪` 4/5 · `+2`"
195
196
  assert expected in block
196
197
  assert "↓" not in block
197
198
 
@@ -202,7 +203,7 @@ def test_streak_reward_ide_negative(cup):
202
203
  "direction": "negative", "streak": 3, "target": 5, "xp_awarded": 1}],
203
204
  env="ide",
204
205
  )
205
- expected = "↓ `edits without testing` · `🔴🔴🔴⚪⚪ 3/5` · `-1`"
206
+ expected = "↓ `edits without testing` · `🟢🟢🟢⚪⚪ 3/5` · `-1`"
206
207
  assert expected in block
207
208
  assert "↑" not in block
208
209
  assert block.startswith("---\n")
@@ -216,7 +217,7 @@ def test_streak_reward_ide_positive(cup):
216
217
  "direction": "positive", "streak": 4, "target": 5, "xp_awarded": 2}],
217
218
  env="ide",
218
219
  )
219
- expected = "↑ `safe git hygiene` · `🔴🔴🔴🔴⚪ 4/5` · `+2`"
220
+ expected = "↑ `safe git hygiene` · `🟢🟢🟢🟢⚪ 4/5` · `+2`"
220
221
  assert expected in block
221
222
  assert "↓" not in block
222
223
 
@@ -285,6 +286,75 @@ def test_graduation_ide_positive(cup):
285
286
  assert "\n\n---" in block # Setext-H2 guard
286
287
 
287
288
 
289
+ # -----------------------------------------------------------------------------
290
+ # Graduation full-bar color: yellow for GRADUATED ⚡️ (negative-direction,
291
+ # weakness retired), black for MASTERED 🌟 (positive-direction, strength
292
+ # locked in). The streak ladder ⚪/🔴 is reserved for active mid-streak
293
+ # attribution — graduation ceremonies get bespoke colors.
294
+ # -----------------------------------------------------------------------------
295
+
296
+ def test_curated_override_wins_over_marker_name(cup):
297
+ """Regression: when a marker carries a `name` that differs from the
298
+ curated WORDING_OVERRIDES entry for that slug, the override MUST win.
299
+ The teammate-found bug was the milestone renderers preferring marker
300
+ name over display_name's resolution chain — defeating the natural-
301
+ language override contract for known awkward phrases."""
302
+ # `commit-without-testing` carries marker name "commit without
303
+ # testing" (analyze.py:350), but the curated override is the richer
304
+ # "committing without testing".
305
+ streak_block = cup._streak_reward_block(
306
+ [{"id": "commit-without-testing", "name": "commit without testing",
307
+ "direction": "negative", "streak": 3, "target": 5, "xp_awarded": 1}],
308
+ env="terminal",
309
+ )
310
+ assert "committing without testing" in streak_block, (
311
+ "streak reward banner ignored the curated override"
312
+ )
313
+ assert "`commit without testing`" not in streak_block, (
314
+ "marker name leaked through despite override match"
315
+ )
316
+
317
+ grad_block = cup._graduation_block(
318
+ [{"id": "commit-without-testing", "name": "commit without testing",
319
+ "direction": "negative", "graduated_reason": "absent-5-runs"}],
320
+ env="terminal",
321
+ )
322
+ assert "GRADUATED: committing without testing" in grad_block, (
323
+ "graduation banner ignored the curated override"
324
+ )
325
+
326
+ reg_block = cup._regression_block(
327
+ [{"id": "commit-without-testing", "name": "commit without testing",
328
+ "originally_graduated_at": "2026-04-01"}],
329
+ env="terminal",
330
+ )
331
+ assert "Regressed: committing without testing" in reg_block, (
332
+ "regression banner ignored the curated override"
333
+ )
334
+
335
+
336
+ def test_graduation_negative_full_bar_is_yellow(cup):
337
+ block = cup._graduation_block(
338
+ [{"id": "edits-without-testing", "name": "edits without testing",
339
+ "direction": "negative", "graduated_reason": "absent-5-runs"}],
340
+ env="terminal",
341
+ )
342
+ assert "🟡🟡🟡🟡🟡" in block
343
+ assert "🔴" not in block # red is the streak ladder, not the ceremony
344
+ assert "⚫" not in block # black is reserved for MASTERED
345
+
346
+
347
+ def test_graduation_positive_full_bar_is_black(cup):
348
+ block = cup._graduation_block(
349
+ [{"id": "tests-after-edits", "name": "tests after edits",
350
+ "direction": "positive", "graduated_reason": "present-5-runs"}],
351
+ env="terminal",
352
+ )
353
+ assert "⚫️⚫️⚫️⚫️⚫️" in block
354
+ assert "🔴" not in block
355
+ assert "🟡" not in block # yellow is reserved for GRADUATED
356
+
357
+
288
358
  # -----------------------------------------------------------------------------
289
359
  # _completion_banner — terminal vs IDE, all kinds
290
360
  # -----------------------------------------------------------------------------
@@ -324,9 +394,10 @@ def test_completion_banner_ide_weakness(cup):
324
394
  env="ide",
325
395
  )
326
396
  assert " ---" in block
327
- assert "✅ **Tip cleared** — `edits-without-testing`" in block
397
+ assert "✅ **Tip cleared** — `edits without testing`" in block
398
+ assert "edits-without-testing" not in block # slug must not leak
328
399
  assert "`+2 XP banked`" in block
329
- assert "`streak 🔴🔴⚪⚪⚪ advances next /coach-insights`" in block
400
+ assert "`streak 🟢🟢⚪⚪⚪ advances next /coach-insights`" in block
330
401
 
331
402
 
332
403
  def test_completion_banner_ide_strength(cup):
@@ -339,8 +410,9 @@ def test_completion_banner_ide_strength(cup):
339
410
  env="ide",
340
411
  )
341
412
  assert " ---" in block
342
- assert "💪 **Strength reinforced** — `tests-after-edits`" in block
343
- assert "`strength streak 🔴🔴⚪⚪⚪`" in block
413
+ assert "💪 **Strength reinforced** — `testing after edits`" in block
414
+ assert "tests-after-edits" not in block # slug must not leak
415
+ assert "`strength streak 🟢🟢⚪⚪⚪`" in block
344
416
 
345
417
 
346
418
  # -----------------------------------------------------------------------------
@@ -95,6 +95,13 @@ try:
95
95
  except Exception:
96
96
  def _get_theme(): # type: ignore[no-redef]
97
97
  return "craft"
98
+ try:
99
+ from display_names import display_name as _display_name # noqa: E402
100
+ except Exception:
101
+ def _display_name(entry_id, profile=None): # type: ignore[no-redef]
102
+ if not entry_id:
103
+ return entry_id
104
+ return entry_id.replace("-", " ")
98
105
  PROFILE = COACH_DIR / "profile.yaml"
99
106
  LEVELUP_MARKER = COACH_DIR / ".pending_levelup"
100
107
  GRADUATION_MARKER = COACH_DIR / ".pending_graduation"
@@ -555,7 +562,9 @@ def _levelup_block(data: dict, env: str = "terminal") -> str:
555
562
  )
556
563
 
557
564
 
558
- def _regression_block(regs: list, env: str = "terminal") -> str:
565
+ def _regression_block(
566
+ regs: list, env: str = "terminal", profile: dict | None = None
567
+ ) -> str:
559
568
  """Pre-rendered regression banners. One per dict, stacked.
560
569
  Body is templated — name and originally_graduated_at are
561
570
  substituted, no model-filled sentence."""
@@ -565,7 +574,11 @@ def _regression_block(regs: list, env: str = "terminal") -> str:
565
574
  if not isinstance(r, dict):
566
575
  continue
567
576
  rid = r.get("id", "?")
568
- rname = r.get("name") or rid
577
+ # display_name is the single source of truth: curated override →
578
+ # profile.name → humanized slug. Marker name is intentionally
579
+ # ignored here so a curated WORDING_OVERRIDES entry always wins
580
+ # over whatever wording the marker happened to carry at write time.
581
+ rname = _display_name(rid, profile) if rid != "?" else rid
569
582
  originally_at = r.get("originally_graduated_at", "?")
570
583
  sentence = (
571
584
  f"Re-detected this run, so it's off the mastered list "
@@ -582,7 +595,9 @@ def _regression_block(regs: list, env: str = "terminal") -> str:
582
595
  return "\n\n".join(bodies_terminal)
583
596
 
584
597
 
585
- def _streak_reward_block(rewards: list, env: str = "terminal") -> str:
598
+ def _streak_reward_block(
599
+ rewards: list, env: str = "terminal", profile: dict | None = None
600
+ ) -> str:
586
601
  """Pre-rendered mid-streak reward banners. Small wins — tighter than
587
602
  graduations so they feel like dopamine pulses, not ceremonies.
588
603
  Direction-aware glyph: positive→↑, negative→↓."""
@@ -592,12 +607,15 @@ def _streak_reward_block(rewards: list, env: str = "terminal") -> str:
592
607
  if not isinstance(r, dict):
593
608
  continue
594
609
  rid = r.get("id", "?")
595
- rname = r.get("name") or rid
610
+ # See _regression_block: display_name is authoritative; the
611
+ # marker's `name` field is intentionally ignored so curated
612
+ # overrides win over whatever the marker carried at write time.
613
+ rname = _display_name(rid, profile) if rid != "?" else rid
596
614
  streak = int(r.get("streak", 0))
597
615
  target = int(r.get("target", 5))
598
616
  xp = int(r.get("xp_awarded", 1))
599
617
  direction = r.get("direction", "negative")
600
- filled = _streak_bar(streak, target)
618
+ filled = _streak_bar(streak, target, fill_glyph="🟢")
601
619
  arrow = "↑" if direction == "positive" else "↓"
602
620
  signed_xp = f"+{xp}" if direction == "positive" else f"-{xp}"
603
621
  bodies_terminal.append(
@@ -614,7 +632,9 @@ def _streak_reward_block(rewards: list, env: str = "terminal") -> str:
614
632
  return "\n".join(bodies_terminal)
615
633
 
616
634
 
617
- def _graduation_block(grads: list, env: str = "terminal") -> str:
635
+ def _graduation_block(
636
+ grads: list, env: str = "terminal", profile: dict | None = None
637
+ ) -> str:
618
638
  """Pre-rendered graduation banners. Direction-picked in Python:
619
639
  positive→MASTERED 🌟, negative→GRADUATED ⚡️. Body sentence is
620
640
  templated, not model-filled."""
@@ -625,23 +645,35 @@ def _graduation_block(grads: list, env: str = "terminal") -> str:
625
645
  negative_sentence = (
626
646
  "5 clean Coach insights runs in a row — weakness retired."
627
647
  )
628
- full_bar = _streak_bar(GRADUATION_STREAK_TARGET, GRADUATION_STREAK_TARGET)
629
648
  bodies_terminal: list[str] = []
630
649
  bodies_ide: list[str] = []
631
650
  for g in grads:
632
651
  if not isinstance(g, dict):
633
652
  continue
634
653
  gid = g.get("id", "?")
635
- gname = g.get("name") or gid
654
+ # See _regression_block: display_name is authoritative; the
655
+ # marker's `name` field is intentionally ignored so curated
656
+ # overrides win over whatever the marker carried at write time.
657
+ gname = _display_name(gid, profile) if gid != "?" else gid
636
658
  direction = g.get("direction", "negative")
637
659
  if direction == "positive":
638
660
  sentence = positive_sentence
639
661
  term_head = f"> 🎓🌟 **MASTERED: {gname}** `+5 XP`"
640
662
  ide_head = f"🎓 **MASTERED** 🌟 — `{gname}` · `+5 XP`"
663
+ full_bar = _streak_bar(
664
+ GRADUATION_STREAK_TARGET,
665
+ GRADUATION_STREAK_TARGET,
666
+ fill_glyph="⚫️",
667
+ )
641
668
  else:
642
669
  sentence = negative_sentence
643
670
  term_head = f"> 🎓⚡️ **GRADUATED: {gname}** `+5 XP`"
644
671
  ide_head = f"🎓 **GRADUATED** ⚡ — `{gname}` · `+5 XP`"
672
+ full_bar = _streak_bar(
673
+ GRADUATION_STREAK_TARGET,
674
+ GRADUATION_STREAK_TARGET,
675
+ fill_glyph="🟡",
676
+ )
645
677
  bodies_terminal.append(f"{term_head}\n> `{full_bar}` — {sentence}")
646
678
  bodies_ide.append(f"{ide_head}\n`{full_bar}` {sentence}")
647
679
  if not bodies_terminal:
@@ -686,6 +718,7 @@ def _assemble_celebrate_block(
686
718
  theme: str = "craft",
687
719
  now: datetime | None = None,
688
720
  streak_oldest: datetime | None = None,
721
+ profile: dict | None = None,
689
722
  ) -> str | None:
690
723
  """Return the full <coach-celebrate>...</coach-celebrate> block, or
691
724
  None if no events. Applies per-pattern dedup (highest streak wins)
@@ -715,6 +748,20 @@ def _assemble_celebrate_block(
715
748
  graduated_ids = {g.get("id") for g in (grads or []) if isinstance(g, dict) and g.get("id")}
716
749
  streak_rewards = [s for s in streak_rewards if s.get("id") not in graduated_ids]
717
750
 
751
+ # Pass C: normalize each reward's `name` field via display_name so
752
+ # both the default-theme and bespoke-theme render paths see the same
753
+ # user-facing wording (override → profile.name → humanized slug).
754
+ # display_name is authoritative — the marker's own `name` field is
755
+ # ignored so a curated WORDING_OVERRIDES entry always wins over
756
+ # whatever wording the marker carried at write time. We rebuild
757
+ # dicts to avoid mutating the marker payload.
758
+ normalized: list[dict] = []
759
+ for s in streak_rewards:
760
+ sid = s.get("id", "?")
761
+ display = _display_name(sid, profile) if sid != "?" else sid
762
+ normalized.append({**s, "name": display})
763
+ streak_rewards = normalized
764
+
718
765
  has_any = bool(levelup) or bool(grads) or bool(regs) or bool(streak_rewards)
719
766
  if not has_any:
720
767
  return None
@@ -730,8 +777,8 @@ def _assemble_celebrate_block(
730
777
  and env == "terminal"
731
778
  ):
732
779
  try:
733
- grads_block = _graduation_block(grads, env=env) if grads else ""
734
- regs_block = _regression_block(regs, env=env) if regs else ""
780
+ grads_block = _graduation_block(grads, env=env, profile=profile) if grads else ""
781
+ regs_block = _regression_block(regs, env=env, profile=profile) if regs else ""
735
782
  bespoke = _render_celebrate_for_theme(
736
783
  theme,
737
784
  streak_rewards=streak_rewards,
@@ -765,15 +812,15 @@ def _assemble_celebrate_block(
765
812
  out.append("")
766
813
 
767
814
  if regs:
768
- out.append(_regression_block(regs, env=env))
815
+ out.append(_regression_block(regs, env=env, profile=profile))
769
816
  if streak_rewards:
770
817
  if regs:
771
818
  out.append("")
772
- out.append(_streak_reward_block(streak_rewards, env=env))
819
+ out.append(_streak_reward_block(streak_rewards, env=env, profile=profile))
773
820
  if grads:
774
821
  if regs or streak_rewards:
775
822
  out.append("")
776
- out.append(_graduation_block(grads, env=env))
823
+ out.append(_graduation_block(grads, env=env, profile=profile))
777
824
  if levelup:
778
825
  if regs or streak_rewards or grads:
779
826
  out.append("")
@@ -1633,19 +1680,47 @@ def _detect_completions(
1633
1680
  return completed
1634
1681
 
1635
1682
 
1636
- def _streak_bar(streak: int, target: int = GRADUATION_STREAK_TARGET) -> str:
1637
- """Streak bar: 🔴🔴🔴⚪⚪ for 3/5. Emoji glyphs carry color
1638
- intrinsically red fill for earned positions, hollow white for
1639
- remaining — so the bar reads identically in Markdown chat and in
1640
- /coach status without ANSI escapes."""
1683
+ def _streak_bar(
1684
+ streak: int,
1685
+ target: int = GRADUATION_STREAK_TARGET,
1686
+ *,
1687
+ fill_glyph: str = "🔴",
1688
+ empty_glyph: str = "⚪",
1689
+ ) -> str:
1690
+ """Streak bar: e.g. 🔴🔴🔴⚪⚪ for 3/5. Emoji glyphs carry color
1691
+ intrinsically so the bar reads identically in Markdown chat and in
1692
+ /coach status without ANSI escapes. Default 🔴/⚪ is reserved for
1693
+ the tip-attribution streak ladder. Other surfaces pass their own
1694
+ glyphs — 🟢/⚪ for baseline progress (mid-streak banners, ack
1695
+ banners, /coach status rows), 🟡/⚫️ for graduation ceremony bars."""
1641
1696
  streak = max(0, min(streak, target))
1642
- return "🔴" * streak + "⚪" * (target - streak)
1697
+ return fill_glyph * streak + empty_glyph * (target - streak)
1643
1698
 
1644
1699
 
1645
1700
  def _completion_banner(
1646
- entries: list[tuple[str, dict]], env: str = "terminal"
1701
+ entries: list[tuple[str, dict]],
1702
+ env: str = "terminal",
1703
+ theme: str = "craft",
1704
+ profile: dict | None = None,
1647
1705
  ) -> str:
1648
- """Render instructions for tip-complete ack banners."""
1706
+ """Render instructions for tip-complete ack banners.
1707
+
1708
+ `theme` selects per-theme phrasing for the "Tip cleared" /
1709
+ "Strength reinforced" labels (military → "Mission accomplished",
1710
+ hacker → "Exploit landed", etc.). Default `craft` preserves the
1711
+ original wording.
1712
+
1713
+ `profile` is threaded through so `display_name()` can resolve
1714
+ entry_ids to user-facing names (override → profile.name → humanized
1715
+ slug). When None or unavailable, falls back to humanized slug.
1716
+ """
1717
+ try:
1718
+ from banner_themes import completion_labels # local import keeps cold path light
1719
+ labels = completion_labels(theme)
1720
+ except Exception:
1721
+ labels = {"tip_cleared": "Tip cleared", "strength_reinforced": "Strength reinforced"}
1722
+ tip_cleared = labels["tip_cleared"]
1723
+ strength_reinforced = labels["strength_reinforced"]
1649
1724
  lines: list[str] = []
1650
1725
  lines.append("<coach-tip-complete>")
1651
1726
  lines.append(
@@ -1667,6 +1742,10 @@ def _completion_banner(
1667
1742
  spec = entry.get("spec") or {}
1668
1743
  kind = entry.get("kind", "weakness")
1669
1744
  entry_id = entry.get("entry_id") or ""
1745
+ # Display name resolves curated → profile.name → humanized slug.
1746
+ # Skill cases keep the slash-command form (`/{entry_id}`) since
1747
+ # that's a literal command the user types, not a pattern label.
1748
+ entry_display = _display_name(entry_id, profile)
1670
1749
  streak = int(
1671
1750
  entry.get("positive_streak" if kind == "strength" else "clean_streak", 0)
1672
1751
  )
@@ -1678,18 +1757,18 @@ def _completion_banner(
1678
1757
  if action == "skill_invoke" and kind == "skill":
1679
1758
  banner = (
1680
1759
  f" ---\n"
1681
- f" ✅ **Tip cleared** — `/{entry_id}` invoked · "
1760
+ f" ✅ **{tip_cleared}** — `/{entry_id}` invoked · "
1682
1761
  f"`+{SKILL_XP_PER_UNIQUE} XP banked this session`\n"
1683
1762
  f"\n"
1684
1763
  f" ---"
1685
1764
  )
1686
1765
  elif action in action_labels:
1687
1766
  label, xp = action_labels[action]
1688
- bar = _streak_bar(streak)
1767
+ bar = _streak_bar(streak, fill_glyph="🟢")
1689
1768
  if kind == "strength":
1690
1769
  banner = (
1691
1770
  f" ---\n"
1692
- f" 💪 **Strength reinforced** — `{entry_id}` · "
1771
+ f" 💪 **{strength_reinforced}** — `{entry_display}` · "
1693
1772
  f"`{label}` · `+{xp} XP` · `strength streak {bar}`\n"
1694
1773
  f"\n"
1695
1774
  f" ---"
@@ -1697,7 +1776,7 @@ def _completion_banner(
1697
1776
  else:
1698
1777
  banner = (
1699
1778
  f" ---\n"
1700
- f" ✅ **Tip cleared** — `{entry_id}` · `{label}` · "
1779
+ f" ✅ **{tip_cleared}** — `{entry_display}` · `{label}` · "
1701
1780
  f"`+{xp} XP banked` · `streak {bar} advances next /coach-insights`\n"
1702
1781
  f"\n"
1703
1782
  f" ---"
@@ -1706,41 +1785,41 @@ def _completion_banner(
1706
1785
  xp = int(spec.get("xp", 0) or 0)
1707
1786
  desc = spec.get("description") or action or "action detected"
1708
1787
  xp_pill = f" · `+{xp} XP`" if xp > 0 else ""
1709
- prefix = "Strength reinforced" if kind == "strength" else "Tip cleared"
1788
+ prefix = strength_reinforced if kind == "strength" else tip_cleared
1710
1789
  emoji = "💪" if kind == "strength" else "✅"
1711
1790
  banner = (
1712
1791
  f" ---\n"
1713
- f" {emoji} **{prefix}** — `{entry_id}` · `{desc}`{xp_pill}\n"
1792
+ f" {emoji} **{prefix}** — `{entry_display}` · `{desc}`{xp_pill}\n"
1714
1793
  f"\n"
1715
1794
  f" ---"
1716
1795
  )
1717
1796
  lines.append(banner)
1718
1797
  continue
1719
- # Terminal path (unchanged)
1798
+ # Terminal path
1720
1799
  if action == "skill_invoke" and kind == "skill":
1721
1800
  banner = (
1722
- f" > ✅ **Tip cleared** — `/{entry_id}` invoked · "
1801
+ f" > ✅ **{tip_cleared}** — `/{entry_id}` invoked · "
1723
1802
  f"`+{SKILL_XP_PER_UNIQUE} XP` banked this session"
1724
1803
  )
1725
1804
  elif action in action_labels:
1726
1805
  label, xp = action_labels[action]
1727
- bar = _streak_bar(streak)
1806
+ bar = _streak_bar(streak, fill_glyph="🟢")
1728
1807
  if kind == "strength":
1729
- lines.append(f" > 💪 Strength reinforced — {label}")
1730
- lines.append(f" > +{xp} XP · {entry_id} strength streak {bar}")
1808
+ lines.append(f" > 💪 {strength_reinforced} — {label}")
1809
+ lines.append(f" > +{xp} XP · {entry_display} strength streak {bar}")
1731
1810
  continue
1732
- prefix = "Strength reinforced" if kind == "strength" else "Tip cleared"
1811
+ prefix = strength_reinforced if kind == "strength" else tip_cleared
1733
1812
  streak_label = "strength streak" if kind == "strength" else "streak"
1734
1813
  banner = (
1735
1814
  f" > ✅ **{prefix}** — {label} · `+{xp} XP` · "
1736
- f"`{entry_id}` {streak_label} {bar} (advances on next /coach-insights run)"
1815
+ f"`{entry_display}` {streak_label} {bar} (advances on next /coach-insights run)"
1737
1816
  )
1738
1817
  else:
1739
1818
  xp = int(spec.get("xp", 0) or 0)
1740
1819
  desc = spec.get("description") or action or "action detected"
1741
1820
  xp_text = f" · `+{xp} XP`" if xp > 0 else ""
1742
- prefix = "Strength reinforced" if kind == "strength" else "Tip cleared"
1743
- banner = f" > ✅ **{prefix}** — {desc}{xp_text} · `{entry_id}`"
1821
+ prefix = strength_reinforced if kind == "strength" else tip_cleared
1822
+ banner = f" > ✅ **{prefix}** — {desc}{xp_text} · `{entry_display}`"
1744
1823
  lines.append(banner)
1745
1824
  lines.append("")
1746
1825
  lines.append("Rules:")
@@ -1778,6 +1857,8 @@ def _streak_stage_label(kind: str, streak: int, target: int) -> str:
1778
1857
  if streak >= 3:
1779
1858
  return "🌶️ Heating up"
1780
1859
  if streak >= 2:
1860
+ return "♨️ Let 'em cook"
1861
+ if streak >= 1:
1781
1862
  return "🌡️ Warming up"
1782
1863
  return "🧊 Ice cold"
1783
1864
 
@@ -2197,9 +2278,29 @@ def main() -> None:
2197
2278
  if ack_at and ack_at < cutoff:
2198
2279
  pending.pop(tip_id, None)
2199
2280
  _save_tip_state_unlocked(tip_state)
2281
+ # Theme is read once and used by both the completion banner
2282
+ # (per-theme "Tip cleared" labels) and the celebrate dispatch
2283
+ # (bespoke streak shapes). Failures fall back to "craft" so a
2284
+ # missing/corrupt config can never break rendering.
2285
+ try:
2286
+ theme = _get_theme()
2287
+ except Exception:
2288
+ theme = "craft"
2289
+ # Profile is read once and threaded into every banner that
2290
+ # mentions a pattern by name — display_name() resolves entry_ids
2291
+ # to user-facing wording (curated override → profile.name →
2292
+ # humanized slug). Failures fall back to None so display_name
2293
+ # uses the slug-humanization path.
2294
+ try:
2295
+ display_profile = _load_profile()
2296
+ except Exception:
2297
+ display_profile = None
2298
+
2200
2299
  completion_block: str | None = None
2201
2300
  if completions:
2202
- completion_block = _completion_banner(completions, env=env)
2301
+ completion_block = _completion_banner(
2302
+ completions, env=env, theme=theme, profile=display_profile
2303
+ )
2203
2304
 
2204
2305
  levelup = _read_and_consume(LEVELUP_MARKER, session_key, now)
2205
2306
  grad_data = _read_and_consume(GRADUATION_MARKER, session_key, now)
@@ -2223,13 +2324,9 @@ def main() -> None:
2223
2324
  for p in (levelup, grad_data, reg_data, streak_data)
2224
2325
  )
2225
2326
 
2226
- # Defensive read of theme + streak window. Both are optional from
2227
- # _assemble_celebrate_block's POV only consumed by the bespoke
2228
- # dispatch so failures here fall through to default rendering.
2229
- try:
2230
- theme = _get_theme()
2231
- except Exception:
2232
- theme = "craft"
2327
+ # `theme` was loaded above (used by both _completion_banner and
2328
+ # the bespoke celebrate dispatch). Streak window is optional and
2329
+ # only consumed by the bespoke dispatch.
2233
2330
  streak_oldest = None
2234
2331
  if isinstance(streak_data, dict):
2235
2332
  streak_oldest = _parse_iso(streak_data.get("oldest_entry_at"))
@@ -2244,6 +2341,7 @@ def main() -> None:
2244
2341
  theme=theme,
2245
2342
  now=now,
2246
2343
  streak_oldest=streak_oldest,
2344
+ profile=display_profile,
2247
2345
  )
2248
2346
  celebrate_blocks: list[str] = [celebrate_block] if celebrate_block else []
2249
2347
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rm0nroe/coach-claw",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "A self-evolving coaching layer for Claude Code.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rm0nroe.github.io/coach-claw/",