@rm0nroe/coach-claw 1.0.6 → 1.0.7

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"]
@@ -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
 
@@ -178,7 +178,7 @@ def test_streak_reward_terminal_negative(cup):
178
178
  "direction": "negative", "streak": 3, "target": 5, "xp_awarded": 1}],
179
179
  env="terminal",
180
180
  )
181
- expected = "> ↓ `edits without testing` `🔴🔴🔴⚪⚪` 3/5 · `-1`"
181
+ expected = "> ↓ `edits without testing` `🟢🟢🟢⚪⚪` 3/5 · `-1`"
182
182
  assert expected in block
183
183
  assert "↑" not in block
184
184
  assert "edits-without-testing" not in block # slug must not leak
@@ -191,7 +191,7 @@ def test_streak_reward_terminal_positive(cup):
191
191
  "direction": "positive", "streak": 4, "target": 5, "xp_awarded": 2}],
192
192
  env="terminal",
193
193
  )
194
- expected = "> ↑ `safe git hygiene` `🔴🔴🔴🔴⚪` 4/5 · `+2`"
194
+ expected = "> ↑ `safe git hygiene` `🟢🟢🟢🟢⚪` 4/5 · `+2`"
195
195
  assert expected in block
196
196
  assert "↓" not in block
197
197
 
@@ -202,7 +202,7 @@ def test_streak_reward_ide_negative(cup):
202
202
  "direction": "negative", "streak": 3, "target": 5, "xp_awarded": 1}],
203
203
  env="ide",
204
204
  )
205
- expected = "↓ `edits without testing` · `🔴🔴🔴⚪⚪ 3/5` · `-1`"
205
+ expected = "↓ `edits without testing` · `🟢🟢🟢⚪⚪ 3/5` · `-1`"
206
206
  assert expected in block
207
207
  assert "↑" not in block
208
208
  assert block.startswith("---\n")
@@ -216,7 +216,7 @@ def test_streak_reward_ide_positive(cup):
216
216
  "direction": "positive", "streak": 4, "target": 5, "xp_awarded": 2}],
217
217
  env="ide",
218
218
  )
219
- expected = "↑ `safe git hygiene` · `🔴🔴🔴🔴⚪ 4/5` · `+2`"
219
+ expected = "↑ `safe git hygiene` · `🟢🟢🟢🟢⚪ 4/5` · `+2`"
220
220
  assert expected in block
221
221
  assert "↓" not in block
222
222
 
@@ -285,6 +285,35 @@ def test_graduation_ide_positive(cup):
285
285
  assert "\n\n---" in block # Setext-H2 guard
286
286
 
287
287
 
288
+ # -----------------------------------------------------------------------------
289
+ # Graduation full-bar color: yellow for GRADUATED ⚡️ (negative-direction,
290
+ # weakness retired), black for MASTERED 🌟 (positive-direction, strength
291
+ # locked in). The streak ladder ⚪/🔴 is reserved for active mid-streak
292
+ # attribution — graduation ceremonies get bespoke colors.
293
+ # -----------------------------------------------------------------------------
294
+
295
+ def test_graduation_negative_full_bar_is_yellow(cup):
296
+ block = cup._graduation_block(
297
+ [{"id": "edits-without-testing", "name": "edits without testing",
298
+ "direction": "negative", "graduated_reason": "absent-5-runs"}],
299
+ env="terminal",
300
+ )
301
+ assert "🟡🟡🟡🟡🟡" in block
302
+ assert "🔴" not in block # red is the streak ladder, not the ceremony
303
+ assert "⚫" not in block # black is reserved for MASTERED
304
+
305
+
306
+ def test_graduation_positive_full_bar_is_black(cup):
307
+ block = cup._graduation_block(
308
+ [{"id": "tests-after-edits", "name": "tests after edits",
309
+ "direction": "positive", "graduated_reason": "present-5-runs"}],
310
+ env="terminal",
311
+ )
312
+ assert "⚫️⚫️⚫️⚫️⚫️" in block
313
+ assert "🔴" not in block
314
+ assert "🟡" not in block # yellow is reserved for GRADUATED
315
+
316
+
288
317
  # -----------------------------------------------------------------------------
289
318
  # _completion_banner — terminal vs IDE, all kinds
290
319
  # -----------------------------------------------------------------------------
@@ -324,9 +353,10 @@ def test_completion_banner_ide_weakness(cup):
324
353
  env="ide",
325
354
  )
326
355
  assert " ---" in block
327
- assert "✅ **Tip cleared** — `edits-without-testing`" in block
356
+ assert "✅ **Tip cleared** — `edits without testing`" in block
357
+ assert "edits-without-testing" not in block # slug must not leak
328
358
  assert "`+2 XP banked`" in block
329
- assert "`streak 🔴🔴⚪⚪⚪ advances next /coach-insights`" in block
359
+ assert "`streak 🟢🟢⚪⚪⚪ advances next /coach-insights`" in block
330
360
 
331
361
 
332
362
  def test_completion_banner_ide_strength(cup):
@@ -339,8 +369,9 @@ def test_completion_banner_ide_strength(cup):
339
369
  env="ide",
340
370
  )
341
371
  assert " ---" in block
342
- assert "💪 **Strength reinforced** — `tests-after-edits`" in block
343
- assert "`strength streak 🔴🔴⚪⚪⚪`" in block
372
+ assert "💪 **Strength reinforced** — `testing after edits`" in block
373
+ assert "tests-after-edits" not in block # slug must not leak
374
+ assert "`strength streak 🟢🟢⚪⚪⚪`" in block
344
375
 
345
376
 
346
377
  # -----------------------------------------------------------------------------
@@ -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,12 @@ 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
+ rname = _display_name(rid, profile) if rid != "?" else rid
578
+ # Marker may carry richer wording than display_name resolves;
579
+ # prefer the explicit name field when present and non-slug.
580
+ marker_name = r.get("name")
581
+ if marker_name and marker_name != rid:
582
+ rname = marker_name
569
583
  originally_at = r.get("originally_graduated_at", "?")
570
584
  sentence = (
571
585
  f"Re-detected this run, so it's off the mastered list "
@@ -582,7 +596,9 @@ def _regression_block(regs: list, env: str = "terminal") -> str:
582
596
  return "\n\n".join(bodies_terminal)
583
597
 
584
598
 
585
- def _streak_reward_block(rewards: list, env: str = "terminal") -> str:
599
+ def _streak_reward_block(
600
+ rewards: list, env: str = "terminal", profile: dict | None = None
601
+ ) -> str:
586
602
  """Pre-rendered mid-streak reward banners. Small wins — tighter than
587
603
  graduations so they feel like dopamine pulses, not ceremonies.
588
604
  Direction-aware glyph: positive→↑, negative→↓."""
@@ -592,12 +608,15 @@ def _streak_reward_block(rewards: list, env: str = "terminal") -> str:
592
608
  if not isinstance(r, dict):
593
609
  continue
594
610
  rid = r.get("id", "?")
595
- rname = r.get("name") or rid
611
+ rname = _display_name(rid, profile) if rid != "?" else rid
612
+ marker_name = r.get("name")
613
+ if marker_name and marker_name != rid:
614
+ rname = marker_name
596
615
  streak = int(r.get("streak", 0))
597
616
  target = int(r.get("target", 5))
598
617
  xp = int(r.get("xp_awarded", 1))
599
618
  direction = r.get("direction", "negative")
600
- filled = _streak_bar(streak, target)
619
+ filled = _streak_bar(streak, target, fill_glyph="🟢")
601
620
  arrow = "↑" if direction == "positive" else "↓"
602
621
  signed_xp = f"+{xp}" if direction == "positive" else f"-{xp}"
603
622
  bodies_terminal.append(
@@ -614,7 +633,9 @@ def _streak_reward_block(rewards: list, env: str = "terminal") -> str:
614
633
  return "\n".join(bodies_terminal)
615
634
 
616
635
 
617
- def _graduation_block(grads: list, env: str = "terminal") -> str:
636
+ def _graduation_block(
637
+ grads: list, env: str = "terminal", profile: dict | None = None
638
+ ) -> str:
618
639
  """Pre-rendered graduation banners. Direction-picked in Python:
619
640
  positive→MASTERED 🌟, negative→GRADUATED ⚡️. Body sentence is
620
641
  templated, not model-filled."""
@@ -625,23 +646,35 @@ def _graduation_block(grads: list, env: str = "terminal") -> str:
625
646
  negative_sentence = (
626
647
  "5 clean Coach insights runs in a row — weakness retired."
627
648
  )
628
- full_bar = _streak_bar(GRADUATION_STREAK_TARGET, GRADUATION_STREAK_TARGET)
629
649
  bodies_terminal: list[str] = []
630
650
  bodies_ide: list[str] = []
631
651
  for g in grads:
632
652
  if not isinstance(g, dict):
633
653
  continue
634
654
  gid = g.get("id", "?")
635
- gname = g.get("name") or gid
655
+ gname = _display_name(gid, profile) if gid != "?" else gid
656
+ marker_name = g.get("name")
657
+ if marker_name and marker_name != gid:
658
+ gname = marker_name
636
659
  direction = g.get("direction", "negative")
637
660
  if direction == "positive":
638
661
  sentence = positive_sentence
639
662
  term_head = f"> 🎓🌟 **MASTERED: {gname}** `+5 XP`"
640
663
  ide_head = f"🎓 **MASTERED** 🌟 — `{gname}` · `+5 XP`"
664
+ full_bar = _streak_bar(
665
+ GRADUATION_STREAK_TARGET,
666
+ GRADUATION_STREAK_TARGET,
667
+ fill_glyph="⚫️",
668
+ )
641
669
  else:
642
670
  sentence = negative_sentence
643
671
  term_head = f"> 🎓⚡️ **GRADUATED: {gname}** `+5 XP`"
644
672
  ide_head = f"🎓 **GRADUATED** ⚡ — `{gname}` · `+5 XP`"
673
+ full_bar = _streak_bar(
674
+ GRADUATION_STREAK_TARGET,
675
+ GRADUATION_STREAK_TARGET,
676
+ fill_glyph="🟡",
677
+ )
645
678
  bodies_terminal.append(f"{term_head}\n> `{full_bar}` — {sentence}")
646
679
  bodies_ide.append(f"{ide_head}\n`{full_bar}` {sentence}")
647
680
  if not bodies_terminal:
@@ -686,6 +719,7 @@ def _assemble_celebrate_block(
686
719
  theme: str = "craft",
687
720
  now: datetime | None = None,
688
721
  streak_oldest: datetime | None = None,
722
+ profile: dict | None = None,
689
723
  ) -> str | None:
690
724
  """Return the full <coach-celebrate>...</coach-celebrate> block, or
691
725
  None if no events. Applies per-pattern dedup (highest streak wins)
@@ -715,6 +749,24 @@ def _assemble_celebrate_block(
715
749
  graduated_ids = {g.get("id") for g in (grads or []) if isinstance(g, dict) and g.get("id")}
716
750
  streak_rewards = [s for s in streak_rewards if s.get("id") not in graduated_ids]
717
751
 
752
+ # Pass C: normalize each reward's `name` field via display_name so both
753
+ # the default-theme and bespoke-theme render paths see user-facing
754
+ # wording (override → profile.name → humanized slug). The bespoke path
755
+ # in banner_themes.py reads `r["name"]` directly without any
756
+ # display-name lookup; without this pass it would leak slugs (e.g.,
757
+ # `under-planning` if profile.name is broken). We rebuild dicts to
758
+ # avoid mutating the marker payload.
759
+ normalized: list[dict] = []
760
+ for s in streak_rewards:
761
+ sid = s.get("id", "?")
762
+ marker_name = s.get("name")
763
+ if marker_name and marker_name != sid:
764
+ display = marker_name
765
+ else:
766
+ display = _display_name(sid, profile) if sid != "?" else sid
767
+ normalized.append({**s, "name": display})
768
+ streak_rewards = normalized
769
+
718
770
  has_any = bool(levelup) or bool(grads) or bool(regs) or bool(streak_rewards)
719
771
  if not has_any:
720
772
  return None
@@ -730,8 +782,8 @@ def _assemble_celebrate_block(
730
782
  and env == "terminal"
731
783
  ):
732
784
  try:
733
- grads_block = _graduation_block(grads, env=env) if grads else ""
734
- regs_block = _regression_block(regs, env=env) if regs else ""
785
+ grads_block = _graduation_block(grads, env=env, profile=profile) if grads else ""
786
+ regs_block = _regression_block(regs, env=env, profile=profile) if regs else ""
735
787
  bespoke = _render_celebrate_for_theme(
736
788
  theme,
737
789
  streak_rewards=streak_rewards,
@@ -765,15 +817,15 @@ def _assemble_celebrate_block(
765
817
  out.append("")
766
818
 
767
819
  if regs:
768
- out.append(_regression_block(regs, env=env))
820
+ out.append(_regression_block(regs, env=env, profile=profile))
769
821
  if streak_rewards:
770
822
  if regs:
771
823
  out.append("")
772
- out.append(_streak_reward_block(streak_rewards, env=env))
824
+ out.append(_streak_reward_block(streak_rewards, env=env, profile=profile))
773
825
  if grads:
774
826
  if regs or streak_rewards:
775
827
  out.append("")
776
- out.append(_graduation_block(grads, env=env))
828
+ out.append(_graduation_block(grads, env=env, profile=profile))
777
829
  if levelup:
778
830
  if regs or streak_rewards or grads:
779
831
  out.append("")
@@ -1633,19 +1685,47 @@ def _detect_completions(
1633
1685
  return completed
1634
1686
 
1635
1687
 
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."""
1688
+ def _streak_bar(
1689
+ streak: int,
1690
+ target: int = GRADUATION_STREAK_TARGET,
1691
+ *,
1692
+ fill_glyph: str = "🔴",
1693
+ empty_glyph: str = "⚪",
1694
+ ) -> str:
1695
+ """Streak bar: e.g. 🔴🔴🔴⚪⚪ for 3/5. Emoji glyphs carry color
1696
+ intrinsically so the bar reads identically in Markdown chat and in
1697
+ /coach status without ANSI escapes. Default 🔴/⚪ is reserved for
1698
+ the tip-attribution streak ladder. Other surfaces pass their own
1699
+ glyphs — 🟢/⚪ for baseline progress (mid-streak banners, ack
1700
+ banners, /coach status rows), 🟡/⚫️ for graduation ceremony bars."""
1641
1701
  streak = max(0, min(streak, target))
1642
- return "🔴" * streak + "⚪" * (target - streak)
1702
+ return fill_glyph * streak + empty_glyph * (target - streak)
1643
1703
 
1644
1704
 
1645
1705
  def _completion_banner(
1646
- entries: list[tuple[str, dict]], env: str = "terminal"
1706
+ entries: list[tuple[str, dict]],
1707
+ env: str = "terminal",
1708
+ theme: str = "craft",
1709
+ profile: dict | None = None,
1647
1710
  ) -> str:
1648
- """Render instructions for tip-complete ack banners."""
1711
+ """Render instructions for tip-complete ack banners.
1712
+
1713
+ `theme` selects per-theme phrasing for the "Tip cleared" /
1714
+ "Strength reinforced" labels (military → "Mission accomplished",
1715
+ hacker → "Exploit landed", etc.). Default `craft` preserves the
1716
+ original wording.
1717
+
1718
+ `profile` is threaded through so `display_name()` can resolve
1719
+ entry_ids to user-facing names (override → profile.name → humanized
1720
+ slug). When None or unavailable, falls back to humanized slug.
1721
+ """
1722
+ try:
1723
+ from banner_themes import completion_labels # local import keeps cold path light
1724
+ labels = completion_labels(theme)
1725
+ except Exception:
1726
+ labels = {"tip_cleared": "Tip cleared", "strength_reinforced": "Strength reinforced"}
1727
+ tip_cleared = labels["tip_cleared"]
1728
+ strength_reinforced = labels["strength_reinforced"]
1649
1729
  lines: list[str] = []
1650
1730
  lines.append("<coach-tip-complete>")
1651
1731
  lines.append(
@@ -1667,6 +1747,10 @@ def _completion_banner(
1667
1747
  spec = entry.get("spec") or {}
1668
1748
  kind = entry.get("kind", "weakness")
1669
1749
  entry_id = entry.get("entry_id") or ""
1750
+ # Display name resolves curated → profile.name → humanized slug.
1751
+ # Skill cases keep the slash-command form (`/{entry_id}`) since
1752
+ # that's a literal command the user types, not a pattern label.
1753
+ entry_display = _display_name(entry_id, profile)
1670
1754
  streak = int(
1671
1755
  entry.get("positive_streak" if kind == "strength" else "clean_streak", 0)
1672
1756
  )
@@ -1678,18 +1762,18 @@ def _completion_banner(
1678
1762
  if action == "skill_invoke" and kind == "skill":
1679
1763
  banner = (
1680
1764
  f" ---\n"
1681
- f" ✅ **Tip cleared** — `/{entry_id}` invoked · "
1765
+ f" ✅ **{tip_cleared}** — `/{entry_id}` invoked · "
1682
1766
  f"`+{SKILL_XP_PER_UNIQUE} XP banked this session`\n"
1683
1767
  f"\n"
1684
1768
  f" ---"
1685
1769
  )
1686
1770
  elif action in action_labels:
1687
1771
  label, xp = action_labels[action]
1688
- bar = _streak_bar(streak)
1772
+ bar = _streak_bar(streak, fill_glyph="🟢")
1689
1773
  if kind == "strength":
1690
1774
  banner = (
1691
1775
  f" ---\n"
1692
- f" 💪 **Strength reinforced** — `{entry_id}` · "
1776
+ f" 💪 **{strength_reinforced}** — `{entry_display}` · "
1693
1777
  f"`{label}` · `+{xp} XP` · `strength streak {bar}`\n"
1694
1778
  f"\n"
1695
1779
  f" ---"
@@ -1697,7 +1781,7 @@ def _completion_banner(
1697
1781
  else:
1698
1782
  banner = (
1699
1783
  f" ---\n"
1700
- f" ✅ **Tip cleared** — `{entry_id}` · `{label}` · "
1784
+ f" ✅ **{tip_cleared}** — `{entry_display}` · `{label}` · "
1701
1785
  f"`+{xp} XP banked` · `streak {bar} advances next /coach-insights`\n"
1702
1786
  f"\n"
1703
1787
  f" ---"
@@ -1706,41 +1790,41 @@ def _completion_banner(
1706
1790
  xp = int(spec.get("xp", 0) or 0)
1707
1791
  desc = spec.get("description") or action or "action detected"
1708
1792
  xp_pill = f" · `+{xp} XP`" if xp > 0 else ""
1709
- prefix = "Strength reinforced" if kind == "strength" else "Tip cleared"
1793
+ prefix = strength_reinforced if kind == "strength" else tip_cleared
1710
1794
  emoji = "💪" if kind == "strength" else "✅"
1711
1795
  banner = (
1712
1796
  f" ---\n"
1713
- f" {emoji} **{prefix}** — `{entry_id}` · `{desc}`{xp_pill}\n"
1797
+ f" {emoji} **{prefix}** — `{entry_display}` · `{desc}`{xp_pill}\n"
1714
1798
  f"\n"
1715
1799
  f" ---"
1716
1800
  )
1717
1801
  lines.append(banner)
1718
1802
  continue
1719
- # Terminal path (unchanged)
1803
+ # Terminal path
1720
1804
  if action == "skill_invoke" and kind == "skill":
1721
1805
  banner = (
1722
- f" > ✅ **Tip cleared** — `/{entry_id}` invoked · "
1806
+ f" > ✅ **{tip_cleared}** — `/{entry_id}` invoked · "
1723
1807
  f"`+{SKILL_XP_PER_UNIQUE} XP` banked this session"
1724
1808
  )
1725
1809
  elif action in action_labels:
1726
1810
  label, xp = action_labels[action]
1727
- bar = _streak_bar(streak)
1811
+ bar = _streak_bar(streak, fill_glyph="🟢")
1728
1812
  if kind == "strength":
1729
- lines.append(f" > 💪 Strength reinforced — {label}")
1730
- lines.append(f" > +{xp} XP · {entry_id} strength streak {bar}")
1813
+ lines.append(f" > 💪 {strength_reinforced} — {label}")
1814
+ lines.append(f" > +{xp} XP · {entry_display} strength streak {bar}")
1731
1815
  continue
1732
- prefix = "Strength reinforced" if kind == "strength" else "Tip cleared"
1816
+ prefix = strength_reinforced if kind == "strength" else tip_cleared
1733
1817
  streak_label = "strength streak" if kind == "strength" else "streak"
1734
1818
  banner = (
1735
1819
  f" > ✅ **{prefix}** — {label} · `+{xp} XP` · "
1736
- f"`{entry_id}` {streak_label} {bar} (advances on next /coach-insights run)"
1820
+ f"`{entry_display}` {streak_label} {bar} (advances on next /coach-insights run)"
1737
1821
  )
1738
1822
  else:
1739
1823
  xp = int(spec.get("xp", 0) or 0)
1740
1824
  desc = spec.get("description") or action or "action detected"
1741
1825
  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}`"
1826
+ prefix = strength_reinforced if kind == "strength" else tip_cleared
1827
+ banner = f" > ✅ **{prefix}** — {desc}{xp_text} · `{entry_display}`"
1744
1828
  lines.append(banner)
1745
1829
  lines.append("")
1746
1830
  lines.append("Rules:")
@@ -1778,6 +1862,8 @@ def _streak_stage_label(kind: str, streak: int, target: int) -> str:
1778
1862
  if streak >= 3:
1779
1863
  return "🌶️ Heating up"
1780
1864
  if streak >= 2:
1865
+ return "♨️ Let 'em cook"
1866
+ if streak >= 1:
1781
1867
  return "🌡️ Warming up"
1782
1868
  return "🧊 Ice cold"
1783
1869
 
@@ -2197,9 +2283,29 @@ def main() -> None:
2197
2283
  if ack_at and ack_at < cutoff:
2198
2284
  pending.pop(tip_id, None)
2199
2285
  _save_tip_state_unlocked(tip_state)
2286
+ # Theme is read once and used by both the completion banner
2287
+ # (per-theme "Tip cleared" labels) and the celebrate dispatch
2288
+ # (bespoke streak shapes). Failures fall back to "craft" so a
2289
+ # missing/corrupt config can never break rendering.
2290
+ try:
2291
+ theme = _get_theme()
2292
+ except Exception:
2293
+ theme = "craft"
2294
+ # Profile is read once and threaded into every banner that
2295
+ # mentions a pattern by name — display_name() resolves entry_ids
2296
+ # to user-facing wording (curated override → profile.name →
2297
+ # humanized slug). Failures fall back to None so display_name
2298
+ # uses the slug-humanization path.
2299
+ try:
2300
+ display_profile = _load_profile()
2301
+ except Exception:
2302
+ display_profile = None
2303
+
2200
2304
  completion_block: str | None = None
2201
2305
  if completions:
2202
- completion_block = _completion_banner(completions, env=env)
2306
+ completion_block = _completion_banner(
2307
+ completions, env=env, theme=theme, profile=display_profile
2308
+ )
2203
2309
 
2204
2310
  levelup = _read_and_consume(LEVELUP_MARKER, session_key, now)
2205
2311
  grad_data = _read_and_consume(GRADUATION_MARKER, session_key, now)
@@ -2223,13 +2329,9 @@ def main() -> None:
2223
2329
  for p in (levelup, grad_data, reg_data, streak_data)
2224
2330
  )
2225
2331
 
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"
2332
+ # `theme` was loaded above (used by both _completion_banner and
2333
+ # the bespoke celebrate dispatch). Streak window is optional and
2334
+ # only consumed by the bespoke dispatch.
2233
2335
  streak_oldest = None
2234
2336
  if isinstance(streak_data, dict):
2235
2337
  streak_oldest = _parse_iso(streak_data.get("oldest_entry_at"))
@@ -2244,6 +2346,7 @@ def main() -> None:
2244
2346
  theme=theme,
2245
2347
  now=now,
2246
2348
  streak_oldest=streak_oldest,
2349
+ profile=display_profile,
2247
2350
  )
2248
2351
  celebrate_blocks: list[str] = [celebrate_block] if celebrate_block else []
2249
2352
 
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.7",
4
4
  "description": "A self-evolving coaching layer for Claude Code.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rm0nroe.github.io/coach-claw/",