@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.
- package/coach/bin/analyze.py +1 -1
- package/coach/bin/banner_themes.py +31 -0
- package/coach/bin/display_names.py +57 -0
- package/coach/bin/status.py +17 -8
- package/coach/tests/test_banner_themes.py +45 -1
- package/coach/tests/test_celebrate_dedup.py +7 -3
- package/coach/tests/test_display_names.py +104 -0
- package/coach/tests/test_hook_bespoke_dispatch.py +48 -4
- package/coach/tests/test_hook_relevance.py +9 -4
- package/coach/tests/test_hook_render_env.py +84 -12
- package/hooks/coach-user-prompt.py +142 -44
- package/package.json +1 -1
package/coach/bin/analyze.py
CHANGED
|
@@ -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": "
|
|
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("-", " ")
|
package/coach/bin/status.py
CHANGED
|
@@ -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(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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} {
|
|
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} {
|
|
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>
|
|
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": "
|
|
391
|
+
grads=[{"id": "pattern-g", "name": "pattern g", "direction": "positive",
|
|
388
392
|
"graduated_reason": "present-5-runs"}],
|
|
389
|
-
regs=[{"id": "
|
|
393
|
+
regs=[{"id": "pattern-r", "name": "pattern r",
|
|
390
394
|
"originally_graduated_at": "2026-04-01"}],
|
|
391
|
-
streak_rewards=[{"id": "
|
|
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
|
|
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`
|
|
130
|
-
assert "> ↓ `heavy subagent delegation`
|
|
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("_
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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 ·
|
|
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:
|
|
150
|
-
#
|
|
151
|
-
|
|
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** — `
|
|
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`
|
|
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`
|
|
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` ·
|
|
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` ·
|
|
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
|
|
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
|
|
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** — `
|
|
343
|
-
assert "
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
|
1697
|
+
return fill_glyph * streak + empty_glyph * (target - streak)
|
|
1643
1698
|
|
|
1644
1699
|
|
|
1645
1700
|
def _completion_banner(
|
|
1646
|
-
entries: list[tuple[str, dict]],
|
|
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" ✅ **
|
|
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" 💪 **
|
|
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" ✅ **
|
|
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 =
|
|
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}** — `{
|
|
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
|
|
1798
|
+
# Terminal path
|
|
1720
1799
|
if action == "skill_invoke" and kind == "skill":
|
|
1721
1800
|
banner = (
|
|
1722
|
-
f" > ✅ **
|
|
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" > 💪
|
|
1730
|
-
lines.append(f" > +{xp} XP · {
|
|
1808
|
+
lines.append(f" > 💪 {strength_reinforced} — {label}")
|
|
1809
|
+
lines.append(f" > +{xp} XP · {entry_display} strength streak {bar}")
|
|
1731
1810
|
continue
|
|
1732
|
-
prefix =
|
|
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"`{
|
|
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 =
|
|
1743
|
-
banner = f" > ✅ **{prefix}** — {desc}{xp_text} · `{
|
|
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(
|
|
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
|
-
#
|
|
2227
|
-
#
|
|
2228
|
-
#
|
|
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
|
|