@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.
- 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_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 +39 -8
- package/hooks/coach-user-prompt.py +147 -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"]
|
|
@@ -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
|
|
|
@@ -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`
|
|
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`
|
|
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` ·
|
|
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` ·
|
|
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
|
|
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
|
|
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** — `
|
|
343
|
-
assert "
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
|
1702
|
+
return fill_glyph * streak + empty_glyph * (target - streak)
|
|
1643
1703
|
|
|
1644
1704
|
|
|
1645
1705
|
def _completion_banner(
|
|
1646
|
-
entries: list[tuple[str, dict]],
|
|
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" ✅ **
|
|
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" 💪 **
|
|
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" ✅ **
|
|
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 =
|
|
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}** — `{
|
|
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
|
|
1803
|
+
# Terminal path
|
|
1720
1804
|
if action == "skill_invoke" and kind == "skill":
|
|
1721
1805
|
banner = (
|
|
1722
|
-
f" > ✅ **
|
|
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" > 💪
|
|
1730
|
-
lines.append(f" > +{xp} XP · {
|
|
1813
|
+
lines.append(f" > 💪 {strength_reinforced} — {label}")
|
|
1814
|
+
lines.append(f" > +{xp} XP · {entry_display} strength streak {bar}")
|
|
1731
1815
|
continue
|
|
1732
|
-
prefix =
|
|
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"`{
|
|
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 =
|
|
1743
|
-
banner = f" > ✅ **{prefix}** — {desc}{xp_text} · `{
|
|
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(
|
|
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
|
-
#
|
|
2227
|
-
#
|
|
2228
|
-
#
|
|
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
|
|