@rm0nroe/coach-claw 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/coach/README.md +99 -0
- package/coach/bin/aggregate_facets.py +274 -0
- package/coach/bin/analyze.py +678 -0
- package/coach/bin/bank.py +247 -0
- package/coach/bin/banner_themes.py +645 -0
- package/coach/bin/coach_paths.py +33 -0
- package/coach/bin/coexistence_check.py +129 -0
- package/coach/bin/configure.py +245 -0
- package/coach/bin/cron_check.py +81 -0
- package/coach/bin/default_statusline.py +135 -0
- package/coach/bin/doctor.py +663 -0
- package/coach/bin/insights-llm.sh +264 -0
- package/coach/bin/insights.sh +163 -0
- package/coach/bin/insights_window.py +111 -0
- package/coach/bin/marker_io.py +154 -0
- package/coach/bin/merge.py +671 -0
- package/coach/bin/redact.py +86 -0
- package/coach/bin/render_env.py +148 -0
- package/coach/bin/reward_hints.py +87 -0
- package/coach/bin/run-insights.sh +20 -0
- package/coach/bin/run_with_lock.py +85 -0
- package/coach/bin/scoring.py +260 -0
- package/coach/bin/skill_inventory.py +215 -0
- package/coach/bin/stats.py +459 -0
- package/coach/bin/status.py +293 -0
- package/coach/bin/statusline_self_patch.py +205 -0
- package/coach/bin/statusline_variants.py +146 -0
- package/coach/bin/statusline_wrap.py +244 -0
- package/coach/bin/statusline_wrap_action.py +460 -0
- package/coach/bin/switch_to_plugin.py +256 -0
- package/coach/bin/themes.py +256 -0
- package/coach/bin/user_config.py +176 -0
- package/coach/bin/xp_accounting.py +98 -0
- package/coach/changelog.md +4 -0
- package/coach/default-statusline-command.sh +19 -0
- package/coach/default-statusline-wrap-command.sh +15 -0
- package/coach/profile.yaml +37 -0
- package/coach/tests/conftest.py +13 -0
- package/coach/tests/test_aggregate_facets.py +379 -0
- package/coach/tests/test_analyze_aggregate.py +153 -0
- package/coach/tests/test_analyze_redaction.py +105 -0
- package/coach/tests/test_analyze_strengths.py +165 -0
- package/coach/tests/test_bank_atomic_write.py +61 -0
- package/coach/tests/test_bank_concurrency.py +126 -0
- package/coach/tests/test_banner_themes.py +981 -0
- package/coach/tests/test_celebrate_dedup.py +409 -0
- package/coach/tests/test_coach_paths.py +50 -0
- package/coach/tests/test_coexistence_check.py +128 -0
- package/coach/tests/test_configure.py +258 -0
- package/coach/tests/test_cron_check.py +118 -0
- package/coach/tests/test_cron_nudge_hook.py +134 -0
- package/coach/tests/test_detection_parity.py +105 -0
- package/coach/tests/test_doctor.py +595 -0
- package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
- package/coach/tests/test_hook_module_resolution.py +116 -0
- package/coach/tests/test_hook_relevance.py +996 -0
- package/coach/tests/test_hook_render_env.py +364 -0
- package/coach/tests/test_hook_session_id_guard.py +160 -0
- package/coach/tests/test_insights_llm.py +759 -0
- package/coach/tests/test_insights_llm_venv_path.py +109 -0
- package/coach/tests/test_insights_window.py +237 -0
- package/coach/tests/test_install.py +1150 -0
- package/coach/tests/test_install_pyyaml_fallback.py +142 -0
- package/coach/tests/test_marker_consumption.py +167 -0
- package/coach/tests/test_marker_writer_locking.py +305 -0
- package/coach/tests/test_merge.py +413 -0
- package/coach/tests/test_no_broken_mktemp.py +90 -0
- package/coach/tests/test_render_env.py +137 -0
- package/coach/tests/test_render_env_glyphs.py +119 -0
- package/coach/tests/test_reward_hints.py +59 -0
- package/coach/tests/test_scoring.py +147 -0
- package/coach/tests/test_session_start_weekly_trigger.py +92 -0
- package/coach/tests/test_skill_inventory.py +368 -0
- package/coach/tests/test_stats_hybrid.py +142 -0
- package/coach/tests/test_status_accounting.py +41 -0
- package/coach/tests/test_statusline_failsafe.py +70 -0
- package/coach/tests/test_statusline_self_patch.py +261 -0
- package/coach/tests/test_statusline_variants.py +110 -0
- package/coach/tests/test_statusline_wrap.py +196 -0
- package/coach/tests/test_statusline_wrap_action.py +408 -0
- package/coach/tests/test_switch_to_plugin.py +360 -0
- package/coach/tests/test_themes.py +104 -0
- package/coach/tests/test_user_config.py +160 -0
- package/coach/tests/test_wrap_announce_hook.py +130 -0
- package/coach/tests/test_xp_accounting.py +55 -0
- package/hooks/coach-session-start.py +536 -0
- package/hooks/coach-user-prompt.py +2288 -0
- package/install-launchd.sh +102 -0
- package/install.sh +597 -0
- package/launchd/com.local.claude-coach.plist.template +34 -0
- package/launchd/run-insights.sh +20 -0
- package/npm/coach-claw.js +259 -0
- package/package.json +52 -0
- package/requirements.txt +11 -0
- package/settings-snippet.json +31 -0
- package/skills/coach/SKILL.md +107 -0
- package/skills/coach-insights/SKILL.md +78 -0
- package/skills/config/SKILL.md +149 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""Per-theme bespoke <coach-celebrate> banner shapes.
|
|
2
|
+
|
|
3
|
+
Five of the twelve themes get bespoke streak-reward + level-up rendering:
|
|
4
|
+
forge, ocean, skyrim, military, hacker. The other seven keep the default
|
|
5
|
+
shape rendered by `hooks/coach-user-prompt.py:_assemble_celebrate_block`.
|
|
6
|
+
|
|
7
|
+
Bespoke shapes are TERMINAL-ONLY. IDE rendering stays on the existing
|
|
8
|
+
HR-framed default for all themes — bespoke ASCII frames clash with the
|
|
9
|
+
WebView's proportional-ish typography.
|
|
10
|
+
|
|
11
|
+
Public API:
|
|
12
|
+
BESPOKE_THEMES — frozenset of theme names with bespoke shapes
|
|
13
|
+
render_celebrate_for_theme(...) — full block renderer, or None if
|
|
14
|
+
the theme is not bespoke
|
|
15
|
+
|
|
16
|
+
Verbatim-render contract: every banner string is fully-resolved Python
|
|
17
|
+
text. No template-fill via the model. Pinned by literal-substring tests
|
|
18
|
+
in coach/tests/test_banner_themes.py.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
from render_env import supports_dual_blade
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
BESPOKE_THEMES = frozenset({"forge", "ocean", "skyrim", "military", "hacker"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# -----------------------------------------------------------------------------
|
|
32
|
+
# Shared helpers — the small kernel that every theme uses.
|
|
33
|
+
|
|
34
|
+
def _meter(streak: int, target: int, filled: str, empty: str) -> str:
|
|
35
|
+
"""Streak meter: `filled * streak + empty * (target - streak)`. Both
|
|
36
|
+
glyphs MUST be 1-cell-wide on the rendering terminal — themes that use
|
|
37
|
+
dual-cell-risk glyphs (e.g., skyrim's ⚔) negotiate fallback before
|
|
38
|
+
calling this."""
|
|
39
|
+
streak = max(0, min(streak, target))
|
|
40
|
+
return filled * streak + empty * max(target - streak, 0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _arrow_xp(direction: str, xp: int, *, with_unit: bool = False) -> str:
|
|
44
|
+
"""`↑N` for positive, `↓N` for negative. Set `with_unit=True` for
|
|
45
|
+
`↑N XP` (military variant)."""
|
|
46
|
+
arrow = "↑" if direction == "positive" else "↓"
|
|
47
|
+
return f"{arrow}{xp} XP" if with_unit else f"{arrow}{xp}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format_window_phrase(now: datetime, oldest: datetime | None) -> dict:
|
|
51
|
+
"""Return per-theme date phrasings for the header window.
|
|
52
|
+
|
|
53
|
+
Keys:
|
|
54
|
+
relative — "yesterday" / "2026-05-06" (skyrim/ocean)
|
|
55
|
+
iso_date — "2026-05-06" (forge / skyrim full)
|
|
56
|
+
iso_datetime — "2026-05-06 19:00" (hacker)
|
|
57
|
+
now_iso_date — "2026-05-07" (military)
|
|
58
|
+
now_zulu_time — "0500Z" (military)
|
|
59
|
+
"""
|
|
60
|
+
now_date = now.strftime("%Y-%m-%d")
|
|
61
|
+
now_zulu = now.strftime("%H%MZ")
|
|
62
|
+
if oldest is None:
|
|
63
|
+
return {
|
|
64
|
+
"relative": "earlier",
|
|
65
|
+
"iso_date": now_date,
|
|
66
|
+
"iso_datetime": now.strftime("%Y-%m-%d %H:%M"),
|
|
67
|
+
"now_iso_date": now_date,
|
|
68
|
+
"now_zulu_time": now_zulu,
|
|
69
|
+
}
|
|
70
|
+
iso_date = oldest.strftime("%Y-%m-%d")
|
|
71
|
+
delta_days = (now.date() - oldest.date()).days
|
|
72
|
+
if delta_days == 0:
|
|
73
|
+
relative = "earlier today"
|
|
74
|
+
elif delta_days == 1:
|
|
75
|
+
relative = "yesterday"
|
|
76
|
+
else:
|
|
77
|
+
relative = iso_date
|
|
78
|
+
return {
|
|
79
|
+
"relative": relative,
|
|
80
|
+
"iso_date": iso_date,
|
|
81
|
+
"iso_datetime": oldest.strftime("%Y-%m-%d %H:%M"),
|
|
82
|
+
"now_iso_date": now_date,
|
|
83
|
+
"now_zulu_time": now_zulu,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# -----------------------------------------------------------------------------
|
|
88
|
+
# Verb-style themes — forge, ocean, skyrim share the row skeleton:
|
|
89
|
+
# `> {meter} {name:<W} {verb:<V} {arrow}{xp}`
|
|
90
|
+
# Differences live in the spec dict (header glyph, label, meter glyphs,
|
|
91
|
+
# verb words, footer template).
|
|
92
|
+
|
|
93
|
+
# Top-N cap for streak rows. Banners that ticked many patterns at once
|
|
94
|
+
# (e.g., catch-up after a multi-day idle) get noisy fast — show the
|
|
95
|
+
# closest-to-graduation N and a tail line counting the rest.
|
|
96
|
+
TOP_N = 5
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _sort_and_truncate(rewards: list[dict]) -> tuple[list[dict], int]:
|
|
100
|
+
"""Group by direction (positive first, then negative), sort each group
|
|
101
|
+
by streak descending (closer-to-graduation first), then truncate to
|
|
102
|
+
TOP_N. Returns (rows_to_render, hidden_count)."""
|
|
103
|
+
def _key(r: dict) -> tuple:
|
|
104
|
+
return (-int(r.get("streak", 0)), r.get("name") or r.get("id", "?"))
|
|
105
|
+
|
|
106
|
+
pos = sorted(
|
|
107
|
+
(r for r in rewards if r.get("direction") == "positive"),
|
|
108
|
+
key=_key,
|
|
109
|
+
)
|
|
110
|
+
neg = sorted(
|
|
111
|
+
(r for r in rewards if r.get("direction") != "positive"),
|
|
112
|
+
key=_key,
|
|
113
|
+
)
|
|
114
|
+
ordered = pos + neg
|
|
115
|
+
return ordered[:TOP_N], max(0, len(ordered) - TOP_N)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _render_verb_style_rows(
|
|
119
|
+
rewards: list[dict],
|
|
120
|
+
*,
|
|
121
|
+
meter_filled: str,
|
|
122
|
+
meter_empty: str,
|
|
123
|
+
verb_positive: str,
|
|
124
|
+
verb_negative: str,
|
|
125
|
+
) -> list[str]:
|
|
126
|
+
"""Return one rendered row string per reward, in input order.
|
|
127
|
+
|
|
128
|
+
Column rule: name padded to (max_name + 4); verb padded to max_verb
|
|
129
|
+
in the input set; 3 spaces between verb and arrow. Matches the locked
|
|
130
|
+
visual cadence from the user's mockups."""
|
|
131
|
+
if not rewards:
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
names = [r.get("name", r.get("id", "?")) for r in rewards]
|
|
135
|
+
verbs = [
|
|
136
|
+
verb_positive if r.get("direction") == "positive" else verb_negative
|
|
137
|
+
for r in rewards
|
|
138
|
+
]
|
|
139
|
+
name_pad = max(len(n) for n in names) + 4
|
|
140
|
+
verb_pad = max(len(v) for v in verbs)
|
|
141
|
+
|
|
142
|
+
out: list[str] = []
|
|
143
|
+
for r, name, verb in zip(rewards, names, verbs):
|
|
144
|
+
meter = _meter(
|
|
145
|
+
int(r.get("streak", 0)),
|
|
146
|
+
int(r.get("target", 5)),
|
|
147
|
+
meter_filled,
|
|
148
|
+
meter_empty,
|
|
149
|
+
)
|
|
150
|
+
xp = _arrow_xp(r.get("direction", "negative"), int(r.get("xp_awarded", 1)))
|
|
151
|
+
out.append(
|
|
152
|
+
f"> {meter} {name:<{name_pad}}{verb:<{verb_pad}} {xp}"
|
|
153
|
+
)
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Spec dict shape — read by `_render_verb_style`.
|
|
158
|
+
# header_glyph: 1-cell glyph at the front of the header line
|
|
159
|
+
# header_label: themed phrase for the header (e.g., "The Anvil")
|
|
160
|
+
# header_window_template: format string with {relative}/{iso_date} keys
|
|
161
|
+
# meter_filled / meter_empty: 1-cell glyphs for the streak meter
|
|
162
|
+
# verb_positive / verb_negative: words slotted into each row
|
|
163
|
+
# levelup_glyph: 1-cell or emoji glyph at the front of the footer
|
|
164
|
+
# levelup_template: format string with {name}/{level}/{next_xp} keys
|
|
165
|
+
SPECS: dict[str, dict] = {
|
|
166
|
+
"ocean": {
|
|
167
|
+
"header_glyph": "🦞",
|
|
168
|
+
"header_label": "Tide turned",
|
|
169
|
+
"header_window_template": "since {relative}",
|
|
170
|
+
"meter_filled": "≋",
|
|
171
|
+
"meter_empty": "·",
|
|
172
|
+
"verb_positive": "rising tide",
|
|
173
|
+
"verb_negative": "ebbing",
|
|
174
|
+
"levelup_glyph": "⚓",
|
|
175
|
+
# ⚓ is a 2-cell emoji; tighten the gap so it visually matches the
|
|
176
|
+
# 1-cell glyph + 2-space pad used by skyrim's ⚜.
|
|
177
|
+
"levelup_glyph_pad": " ",
|
|
178
|
+
# 🌊Deep Water🌊 — Reefer (L8) · next fathom at 125 XP
|
|
179
|
+
"levelup_template": (
|
|
180
|
+
"🌊Deep Water🌊 — {name} (L{level}) · next fathom at {next_xp} XP"
|
|
181
|
+
),
|
|
182
|
+
"levelup_max_template": (
|
|
183
|
+
"🌊Deep Water🌊 — {name} (L{level}) · all fathoms reached"
|
|
184
|
+
),
|
|
185
|
+
},
|
|
186
|
+
"forge": {
|
|
187
|
+
"header_glyph": "⚒",
|
|
188
|
+
"header_label": "The Anvil",
|
|
189
|
+
"header_window_template": "{iso_date} → now",
|
|
190
|
+
"meter_filled": "▰",
|
|
191
|
+
"meter_empty": "▱",
|
|
192
|
+
"verb_positive": "tempering",
|
|
193
|
+
"verb_negative": "quenching",
|
|
194
|
+
"levelup_glyph": "✨",
|
|
195
|
+
# ✨ **Mastersmith** (L8) forged anew · next heat at 125 XP
|
|
196
|
+
"levelup_template": (
|
|
197
|
+
"**{name}** (L{level}) forged anew · next heat at {next_xp} XP"
|
|
198
|
+
),
|
|
199
|
+
"levelup_max_template": (
|
|
200
|
+
"**{name}** (L{level}) — the forge is mastered"
|
|
201
|
+
),
|
|
202
|
+
},
|
|
203
|
+
"skyrim": {
|
|
204
|
+
# Header + meter use ⚔ (U+2694 CROSSED SWORDS). When the active
|
|
205
|
+
# terminal can't render that as a single cell (TERM=dumb, non-UTF8
|
|
206
|
+
# locale, kill switch), the dispatcher swaps both to ✕.
|
|
207
|
+
"header_glyph": "⚔",
|
|
208
|
+
"header_glyph_fallback": "✕",
|
|
209
|
+
"header_label": "Saga",
|
|
210
|
+
"header_window_template": "since {iso_date}",
|
|
211
|
+
"meter_filled": "⚔",
|
|
212
|
+
"meter_filled_fallback": "✕",
|
|
213
|
+
"meter_empty": "·",
|
|
214
|
+
"verb_positive": "oath kept",
|
|
215
|
+
"verb_negative": "curse fades",
|
|
216
|
+
"levelup_glyph": "⚜",
|
|
217
|
+
# ⚜ **Pupil** (L8) — next title at 125 XP
|
|
218
|
+
"levelup_template": (
|
|
219
|
+
"**{name}** (L{level}) — next title at {next_xp} XP"
|
|
220
|
+
),
|
|
221
|
+
"levelup_max_template": (
|
|
222
|
+
"**{name}** (L{level}) — saga complete"
|
|
223
|
+
),
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_spec(theme: str, dual_blade_supported: bool) -> dict:
|
|
229
|
+
"""Apply glyph fallbacks to the spec for the active theme + terminal.
|
|
230
|
+
Returns a plain dict — callers must not mutate the SPECS source."""
|
|
231
|
+
spec = dict(SPECS[theme])
|
|
232
|
+
if not dual_blade_supported:
|
|
233
|
+
for key in ("header_glyph", "meter_filled"):
|
|
234
|
+
fallback_key = f"{key}_fallback"
|
|
235
|
+
if fallback_key in spec:
|
|
236
|
+
spec[key] = spec[fallback_key]
|
|
237
|
+
return spec
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _render_verb_style(
|
|
241
|
+
spec: dict,
|
|
242
|
+
*,
|
|
243
|
+
streak_rewards: list[dict],
|
|
244
|
+
levelup: dict | None,
|
|
245
|
+
now: datetime,
|
|
246
|
+
streak_oldest: datetime | None,
|
|
247
|
+
) -> str:
|
|
248
|
+
"""Compose the bespoke streak + levelup section for a verb-style theme.
|
|
249
|
+
|
|
250
|
+
Returns a string (no enclosing <coach-celebrate> tags — caller wraps).
|
|
251
|
+
Empty streak_rewards + no levelup yields an empty string."""
|
|
252
|
+
parts: list[str] = []
|
|
253
|
+
|
|
254
|
+
if streak_rewards:
|
|
255
|
+
window = _format_window_phrase(now, streak_oldest)
|
|
256
|
+
window_text = spec["header_window_template"].format(**window)
|
|
257
|
+
parts.append(
|
|
258
|
+
f"> {spec['header_glyph']} {spec['header_label']} · {window_text}"
|
|
259
|
+
)
|
|
260
|
+
parts.append(">")
|
|
261
|
+
rows_to_show, hidden = _sort_and_truncate(streak_rewards)
|
|
262
|
+
parts.extend(_render_verb_style_rows(
|
|
263
|
+
rows_to_show,
|
|
264
|
+
meter_filled=spec["meter_filled"],
|
|
265
|
+
meter_empty=spec["meter_empty"],
|
|
266
|
+
verb_positive=spec["verb_positive"],
|
|
267
|
+
verb_negative=spec["verb_negative"],
|
|
268
|
+
))
|
|
269
|
+
if hidden > 0:
|
|
270
|
+
parts.append(f"> …{hidden} more")
|
|
271
|
+
|
|
272
|
+
if levelup:
|
|
273
|
+
# Compose the level-up footer. Pull idx + level name from the
|
|
274
|
+
# marker payload directly — this avoids reading bank state on the
|
|
275
|
+
# hot path (compute_for_render is reserved for the military theme,
|
|
276
|
+
# which actually needs ELO + medal_count).
|
|
277
|
+
level = int(levelup.get("to_idx", 0)) + 1
|
|
278
|
+
name = str(levelup.get("to", "?"))
|
|
279
|
+
# next_xp: the threshold for the level AFTER the one we just
|
|
280
|
+
# crossed. At L50 there is no next level — the renderer swaps to
|
|
281
|
+
# `levelup_max_template` which omits the "next at X XP" suffix.
|
|
282
|
+
next_xp = _next_xp_after_levelup(levelup)
|
|
283
|
+
if next_xp == 0:
|
|
284
|
+
template = spec["levelup_max_template"]
|
|
285
|
+
else:
|
|
286
|
+
template = spec["levelup_template"]
|
|
287
|
+
line = template.format(name=name, level=level, next_xp=next_xp)
|
|
288
|
+
if streak_rewards:
|
|
289
|
+
parts.append(">")
|
|
290
|
+
pad = spec.get("levelup_glyph_pad", " ")
|
|
291
|
+
parts.append(f"> {spec['levelup_glyph']}{pad}{line}")
|
|
292
|
+
|
|
293
|
+
return "\n".join(parts)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# -----------------------------------------------------------------------------
|
|
297
|
+
# Military theme — divergent shape (tag-prefixed rows, rank ribbon footer
|
|
298
|
+
# composed from stats.compute_for_render). Doesn't fit the verb-style
|
|
299
|
+
# helper because rows have a prefix tag instead of a verb suffix.
|
|
300
|
+
|
|
301
|
+
def _render_military(
|
|
302
|
+
*,
|
|
303
|
+
streak_rewards: list[dict],
|
|
304
|
+
levelup: dict | None,
|
|
305
|
+
now: datetime,
|
|
306
|
+
streak_oldest: datetime | None,
|
|
307
|
+
) -> str:
|
|
308
|
+
"""Compose the bespoke military streak + levelup section.
|
|
309
|
+
|
|
310
|
+
Header:
|
|
311
|
+
> ◢ SITREP · 2026-05-07 · 0500Z
|
|
312
|
+
|
|
313
|
+
Rows have a [PUSH]/[HOLD] tag prefix and use ▮▯ meter, no verb column,
|
|
314
|
+
`↑N XP` / `↓N XP` (with unit):
|
|
315
|
+
> [PUSH] ▮▮▮▮▯ safe git hygiene ↑2 XP
|
|
316
|
+
|
|
317
|
+
Footer is a rank ribbon — medal count + Roman numeral + ELO + theme-
|
|
318
|
+
aware level name + next-promotion threshold:
|
|
319
|
+
> ◆ 🎖️🎖️ Ⅷ 1263 **Sensei** · promotion at 125 XP
|
|
320
|
+
"""
|
|
321
|
+
parts: list[str] = []
|
|
322
|
+
|
|
323
|
+
if streak_rewards:
|
|
324
|
+
window = _format_window_phrase(now, streak_oldest)
|
|
325
|
+
parts.append(
|
|
326
|
+
f"> ◢ SITREP · {window['now_iso_date']} · {window['now_zulu_time']}"
|
|
327
|
+
)
|
|
328
|
+
parts.append(">")
|
|
329
|
+
|
|
330
|
+
rows_to_show, hidden = _sort_and_truncate(streak_rewards)
|
|
331
|
+
# Pad name column based on the longest visible name.
|
|
332
|
+
names = [r.get("name") or r.get("id", "?") for r in rows_to_show]
|
|
333
|
+
name_pad = (max((len(n) for n in names), default=0)) + 4
|
|
334
|
+
|
|
335
|
+
for r, name in zip(rows_to_show, names):
|
|
336
|
+
tag = "[PUSH]" if r.get("direction") == "positive" else "[HOLD]"
|
|
337
|
+
meter = _meter(
|
|
338
|
+
int(r.get("streak", 0)),
|
|
339
|
+
int(r.get("target", 5)),
|
|
340
|
+
"▮",
|
|
341
|
+
"▯",
|
|
342
|
+
)
|
|
343
|
+
xp = _arrow_xp(
|
|
344
|
+
r.get("direction", "negative"),
|
|
345
|
+
int(r.get("xp_awarded", 1)),
|
|
346
|
+
with_unit=True,
|
|
347
|
+
)
|
|
348
|
+
parts.append(f"> {tag} {meter} {name:<{name_pad}}{xp}")
|
|
349
|
+
|
|
350
|
+
if hidden > 0:
|
|
351
|
+
parts.append(f"> …{hidden} more")
|
|
352
|
+
|
|
353
|
+
if levelup:
|
|
354
|
+
# Compose rank ribbon from compute_for_render. The lifetime XP at
|
|
355
|
+
# the moment of levelup is `xp_at_levelup` from the marker — the
|
|
356
|
+
# threshold the user just crossed. Session XP = 0 here because the
|
|
357
|
+
# ribbon represents the levelup state, not in-progress slide.
|
|
358
|
+
import stats # late import — stats reads user_config at module init
|
|
359
|
+
meta = stats.compute_for_render(
|
|
360
|
+
int(levelup.get("xp_at_levelup", 0)),
|
|
361
|
+
0,
|
|
362
|
+
)
|
|
363
|
+
# Trust the levelup payload's "to" name in case a custom theme
|
|
364
|
+
# ladder differs from the live LEVELS (e.g., user changed theme
|
|
365
|
+
# between marker write and read). Fall back to compute_for_render's
|
|
366
|
+
# lookup if "to" is missing.
|
|
367
|
+
name = str(levelup.get("to") or meta["name"])
|
|
368
|
+
medals = "🎖️" * meta["medal_count"]
|
|
369
|
+
if streak_rewards:
|
|
370
|
+
parts.append(">")
|
|
371
|
+
# At L50, compute_for_render returns next_xp=None — there is no
|
|
372
|
+
# promotion threshold to render. Swap to a max-rank suffix.
|
|
373
|
+
if meta["next_xp"] is None:
|
|
374
|
+
tail = "· highest grade"
|
|
375
|
+
else:
|
|
376
|
+
tail = f"· promotion at {meta['next_xp']} XP"
|
|
377
|
+
parts.append(
|
|
378
|
+
f"> ◆ {medals} {meta['roman']} {meta['elo']} "
|
|
379
|
+
f"**{name}** {tail}"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
return "\n".join(parts)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# -----------------------------------------------------------------------------
|
|
386
|
+
# Hacker theme — divergent shape (no verb column, snake_case names, log
|
|
387
|
+
# frame). Doesn't fit the verb-style helper, has its own renderer.
|
|
388
|
+
|
|
389
|
+
def _name_to_snake(name: str) -> str:
|
|
390
|
+
"""'safe git hygiene' → 'safe_git_hygiene'. Hacker theme convention."""
|
|
391
|
+
return name.lower().replace(" ", "_").replace("-", "_")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _render_hacker(
|
|
395
|
+
*,
|
|
396
|
+
streak_rewards: list[dict],
|
|
397
|
+
levelup: dict | None,
|
|
398
|
+
now: datetime,
|
|
399
|
+
streak_oldest: datetime | None,
|
|
400
|
+
) -> str:
|
|
401
|
+
"""Compose the bespoke hacker streak + levelup section.
|
|
402
|
+
|
|
403
|
+
Header is a 2-line shell-prompt + dashed-timestamp frame:
|
|
404
|
+
> 👾 [coach@claw ~]$ tail -f session.log
|
|
405
|
+
> ── 2026-05-06 19:00 → now ────────────────
|
|
406
|
+
|
|
407
|
+
Rows use a shell-coded RUN/KILL prefix so direction is preserved
|
|
408
|
+
(both directions earn XP, but they're semantically different events):
|
|
409
|
+
> ▓▓▓▓░ RUN safe_git_hygiene [↑2 xp]
|
|
410
|
+
> ▓▓▓▓░ KILL heavy_subagent_delegation [↑2 xp]
|
|
411
|
+
|
|
412
|
+
The `↑` in `[↑N xp]` denotes direction of XP movement (always up,
|
|
413
|
+
since both row types are gains). RUN/KILL encodes which kind of
|
|
414
|
+
pattern produced the gain — strength reinforced vs weakness retired.
|
|
415
|
+
|
|
416
|
+
Tail (when truncated): ASCII `...` and a help hint:
|
|
417
|
+
> ...4 more (cat /coach/status)
|
|
418
|
+
|
|
419
|
+
Footer: 2-line uplink/breach shape:
|
|
420
|
+
> :: 📡 UPLINK ↑ L8 / Sensei 🥷 ::
|
|
421
|
+
> next breach 🔓 125 xp
|
|
422
|
+
"""
|
|
423
|
+
parts: list[str] = []
|
|
424
|
+
|
|
425
|
+
if streak_rewards:
|
|
426
|
+
window = _format_window_phrase(now, streak_oldest)
|
|
427
|
+
parts.append("> 👾 [coach@claw ~]$ tail -f session.log")
|
|
428
|
+
# The trailing dashes pad the timestamp line to a fixed visual
|
|
429
|
+
# width — matches the locked mockup's `── ... ────────────────`
|
|
430
|
+
# shape. Width chosen so that yesterday-19:00 phrase sits in the
|
|
431
|
+
# middle of the line. 16 trailing dashes covers the locked length.
|
|
432
|
+
parts.append(f"> ── {window['iso_datetime']} → now ────────────────")
|
|
433
|
+
parts.append(">")
|
|
434
|
+
|
|
435
|
+
rows_to_show, hidden = _sort_and_truncate(streak_rewards)
|
|
436
|
+
snake_names = [_name_to_snake(r.get("name") or r.get("id", "?"))
|
|
437
|
+
for r in rows_to_show]
|
|
438
|
+
name_pad = (max((len(n) for n in snake_names), default=0)) + 4
|
|
439
|
+
|
|
440
|
+
for r, sname in zip(rows_to_show, snake_names):
|
|
441
|
+
meter = _meter(
|
|
442
|
+
int(r.get("streak", 0)),
|
|
443
|
+
int(r.get("target", 5)),
|
|
444
|
+
"▓",
|
|
445
|
+
"░",
|
|
446
|
+
)
|
|
447
|
+
xp = int(r.get("xp_awarded", 1))
|
|
448
|
+
# RUN / KILL — direction prefix in shell-coded vocabulary.
|
|
449
|
+
# Both 4 chars wide so the snake_case name column stays
|
|
450
|
+
# aligned across mixed-direction banners.
|
|
451
|
+
prefix = "RUN " if r.get("direction") == "positive" else "KILL"
|
|
452
|
+
parts.append(
|
|
453
|
+
f"> {meter} {prefix} {sname:<{name_pad}}[↑{xp} xp]"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
if hidden > 0:
|
|
457
|
+
parts.append(f"> ...{hidden} more (cat /coach/status)")
|
|
458
|
+
|
|
459
|
+
if levelup:
|
|
460
|
+
level = int(levelup.get("to_idx", 0)) + 1
|
|
461
|
+
name = str(levelup.get("to", "?"))
|
|
462
|
+
next_xp = _next_xp_after_levelup(levelup)
|
|
463
|
+
if streak_rewards:
|
|
464
|
+
parts.append(">")
|
|
465
|
+
parts.append(f"> :: 📡 UPLINK ↑ L{level} / {name} 🥷 ::")
|
|
466
|
+
# At L50, no next breach — swap the threshold line for a max-rank
|
|
467
|
+
# marker that doesn't promise more progression.
|
|
468
|
+
if next_xp == 0:
|
|
469
|
+
parts.append("> root access 🔓 max layer reached")
|
|
470
|
+
else:
|
|
471
|
+
parts.append(f"> next breach 🔓 {next_xp} xp")
|
|
472
|
+
|
|
473
|
+
return "\n".join(parts)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _next_xp_after_levelup(levelup: dict) -> int:
|
|
477
|
+
"""The XP threshold for the level immediately after the one the user
|
|
478
|
+
just crossed. Read from the active LEVELS ladder in stats — themed
|
|
479
|
+
naming + ELO range honor the user's /config selection automatically.
|
|
480
|
+
|
|
481
|
+
Returns 0 when at L50 (no next level)."""
|
|
482
|
+
import stats # late import: stats reads user_config at module init
|
|
483
|
+
to_idx = int(levelup.get("to_idx", 0))
|
|
484
|
+
next_idx = to_idx + 1
|
|
485
|
+
if next_idx >= len(stats.LEVELS):
|
|
486
|
+
return 0
|
|
487
|
+
return int(stats.LEVELS[next_idx][0])
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# -----------------------------------------------------------------------------
|
|
491
|
+
# Public dispatch.
|
|
492
|
+
|
|
493
|
+
def render_celebrate_for_theme(
|
|
494
|
+
theme: str,
|
|
495
|
+
*,
|
|
496
|
+
streak_rewards: list[dict],
|
|
497
|
+
levelup: dict | None,
|
|
498
|
+
grads_block: str = "",
|
|
499
|
+
regs_block: str = "",
|
|
500
|
+
now: datetime,
|
|
501
|
+
streak_oldest: datetime | None = None,
|
|
502
|
+
dual_blade_supported: bool | None = None,
|
|
503
|
+
caught_up: bool = False,
|
|
504
|
+
) -> str | None:
|
|
505
|
+
"""Return the full <coach-celebrate>...</coach-celebrate> block for a
|
|
506
|
+
bespoke theme, or None if the theme isn't in BESPOKE_THEMES (caller
|
|
507
|
+
falls back to default rendering).
|
|
508
|
+
|
|
509
|
+
Composition order inside the block:
|
|
510
|
+
1. Verbatim-render preamble (same as default).
|
|
511
|
+
2. Pre-rendered regressions block (default-shape, may be empty).
|
|
512
|
+
3. Bespoke streak section (theme header + rows).
|
|
513
|
+
4. Pre-rendered graduations block (default-shape, may be empty).
|
|
514
|
+
5. Bespoke level-up footer.
|
|
515
|
+
|
|
516
|
+
Steps 2 + 4 are passed in as already-rendered strings — the hook calls
|
|
517
|
+
`_regression_block` / `_graduation_block` from coach-user-prompt.py to
|
|
518
|
+
produce them (avoids a circular import).
|
|
519
|
+
|
|
520
|
+
`dual_blade_supported`: explicit override for tests. None means
|
|
521
|
+
"probe live env" — production callers leave this unset.
|
|
522
|
+
|
|
523
|
+
Returns None if the bespoke render produces no body (no streak rewards,
|
|
524
|
+
no levelup, no grads, no regs) — caller should not emit an empty block.
|
|
525
|
+
"""
|
|
526
|
+
if theme not in BESPOKE_THEMES:
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
if dual_blade_supported is None:
|
|
530
|
+
dual_blade_supported = supports_dual_blade()
|
|
531
|
+
|
|
532
|
+
if theme in SPECS:
|
|
533
|
+
spec = _resolve_spec(theme, dual_blade_supported)
|
|
534
|
+
bespoke_section = _render_verb_style(
|
|
535
|
+
spec,
|
|
536
|
+
streak_rewards=streak_rewards,
|
|
537
|
+
levelup=levelup,
|
|
538
|
+
now=now,
|
|
539
|
+
streak_oldest=streak_oldest,
|
|
540
|
+
)
|
|
541
|
+
elif theme == "hacker":
|
|
542
|
+
bespoke_section = _render_hacker(
|
|
543
|
+
streak_rewards=streak_rewards,
|
|
544
|
+
levelup=levelup,
|
|
545
|
+
now=now,
|
|
546
|
+
streak_oldest=streak_oldest,
|
|
547
|
+
)
|
|
548
|
+
elif theme == "military":
|
|
549
|
+
bespoke_section = _render_military(
|
|
550
|
+
streak_rewards=streak_rewards,
|
|
551
|
+
levelup=levelup,
|
|
552
|
+
now=now,
|
|
553
|
+
streak_oldest=streak_oldest,
|
|
554
|
+
)
|
|
555
|
+
else: # pragma: no cover — guard against future theme key drift
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
# Guard: if every section is empty, return None so the caller doesn't
|
|
559
|
+
# emit a vacuous <coach-celebrate>...</coach-celebrate>.
|
|
560
|
+
if not (bespoke_section or grads_block or regs_block):
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
out: list[str] = ["<coach-celebrate>"]
|
|
564
|
+
out.append(
|
|
565
|
+
"The block below is a pre-rendered set of milestone banners. "
|
|
566
|
+
"Render this block VERBATIM at the very top of your next response, "
|
|
567
|
+
"BEFORE any other content, then continue with the user's request. "
|
|
568
|
+
"Do NOT re-interpret labels, swap directions, change emoji, or "
|
|
569
|
+
"substitute slugs for names — every character is intentional and "
|
|
570
|
+
"pinned by tests."
|
|
571
|
+
)
|
|
572
|
+
# Catch-up framing: emit ONLY when there's no streak header to carry
|
|
573
|
+
# the date phrasing. The streak header already says "since {date}",
|
|
574
|
+
# making the framing line redundant for streak banners (locked v1
|
|
575
|
+
# decision). Levelup-only / grad-only / reg-only bespoke banners
|
|
576
|
+
# have no theme header, so the framing line earns its place.
|
|
577
|
+
if caught_up and not streak_rewards:
|
|
578
|
+
out.append("")
|
|
579
|
+
out.append(
|
|
580
|
+
"Milestones earned across earlier sessions — not from the "
|
|
581
|
+
"command you just typed."
|
|
582
|
+
)
|
|
583
|
+
out.append("")
|
|
584
|
+
|
|
585
|
+
# Order: regressions first (big news), then bespoke streak section,
|
|
586
|
+
# then graduations (ceremonies between streak ticks and the level-up
|
|
587
|
+
# crown), then bespoke level-up.
|
|
588
|
+
if regs_block:
|
|
589
|
+
out.append(regs_block)
|
|
590
|
+
out.append("")
|
|
591
|
+
if bespoke_section and streak_rewards:
|
|
592
|
+
# Split bespoke_section into streak header+rows vs levelup tail
|
|
593
|
+
# so graduations can land BETWEEN them. The marker for the split
|
|
594
|
+
# is the level-up glyph line — easier to render the parts
|
|
595
|
+
# separately than try to slice a composed string.
|
|
596
|
+
streak_part, _, levelup_part = _split_bespoke_sections(
|
|
597
|
+
bespoke_section
|
|
598
|
+
)
|
|
599
|
+
out.append(streak_part)
|
|
600
|
+
if grads_block:
|
|
601
|
+
out.append("")
|
|
602
|
+
out.append(grads_block)
|
|
603
|
+
if levelup_part:
|
|
604
|
+
out.append("")
|
|
605
|
+
out.append(levelup_part)
|
|
606
|
+
elif bespoke_section:
|
|
607
|
+
# Levelup-only (no streak rewards). Graduations land above.
|
|
608
|
+
if grads_block:
|
|
609
|
+
out.append(grads_block)
|
|
610
|
+
out.append("")
|
|
611
|
+
out.append(bespoke_section)
|
|
612
|
+
elif grads_block:
|
|
613
|
+
out.append(grads_block)
|
|
614
|
+
|
|
615
|
+
out.append("</coach-celebrate>")
|
|
616
|
+
return "\n".join(out)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _split_bespoke_sections(body: str) -> tuple[str, str, str]:
|
|
620
|
+
"""Split a verb-style render into (streak_part, separator, levelup_part).
|
|
621
|
+
|
|
622
|
+
The split is a blank `>` line that precedes the levelup glyph line.
|
|
623
|
+
Returns ("body", "", "") when no levelup is present (single-section)."""
|
|
624
|
+
lines = body.split("\n")
|
|
625
|
+
# Find the last blank-blockquote separator. Levelup is always last.
|
|
626
|
+
# If the streak section's empty divider (between header and rows) is
|
|
627
|
+
# the only `>` line, there's no levelup section.
|
|
628
|
+
# Heuristic: levelup is the FINAL ">" + glyph + body chunk; everything
|
|
629
|
+
# before the immediately-preceding ">" line is the streak section.
|
|
630
|
+
last_blank = -1
|
|
631
|
+
for i, line in enumerate(lines):
|
|
632
|
+
if line == ">":
|
|
633
|
+
last_blank = i
|
|
634
|
+
# If the last blank is followed by a blockquote line that isn't a
|
|
635
|
+
# row indent (rows start with "> ", levelup starts with "> {glyph}"),
|
|
636
|
+
# treat as split point.
|
|
637
|
+
if last_blank == -1 or last_blank == len(lines) - 1:
|
|
638
|
+
return body, "", ""
|
|
639
|
+
tail = lines[last_blank + 1]
|
|
640
|
+
if tail.startswith("> "):
|
|
641
|
+
# Tail is another row — no levelup section.
|
|
642
|
+
return body, "", ""
|
|
643
|
+
streak_part = "\n".join(lines[:last_blank])
|
|
644
|
+
levelup_part = "\n".join(lines[last_blank + 1:])
|
|
645
|
+
return streak_part, "", levelup_part
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Resolve the Coach Claw state directory.
|
|
2
|
+
|
|
3
|
+
Single source of truth for `~/.claude/coach/` path resolution. Honors the
|
|
4
|
+
`COACH_CONFIG_DIR` env var so:
|
|
5
|
+
|
|
6
|
+
- tests can monkeypatch via `monkeypatch.setenv("COACH_CONFIG_DIR", ...)`,
|
|
7
|
+
- the npm CLI wrapper can export `COACH_CONFIG_DIR` to match a custom
|
|
8
|
+
`CLAUDE_DIR` install path,
|
|
9
|
+
- the upcoming Claude Code plugin distribution can point its hooks at a
|
|
10
|
+
plugin-managed data dir without forking any logic.
|
|
11
|
+
|
|
12
|
+
Resolution happens per-call, never cached. That's deliberate — the env
|
|
13
|
+
var may be set after import time (test setup, subprocess wrappers) and
|
|
14
|
+
caching would break that contract. Cost is negligible: a single
|
|
15
|
+
`os.environ.get` per call.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_coach_dir() -> Path:
|
|
24
|
+
"""Return the Coach Claw state directory.
|
|
25
|
+
|
|
26
|
+
Honors `COACH_CONFIG_DIR` (overrides everything); falls back to
|
|
27
|
+
`~/.claude/coach`. Caller is responsible for creating the directory
|
|
28
|
+
if it doesn't exist — this helper only resolves the path.
|
|
29
|
+
"""
|
|
30
|
+
base = os.environ.get("COACH_CONFIG_DIR")
|
|
31
|
+
if base:
|
|
32
|
+
return Path(base)
|
|
33
|
+
return Path.home() / ".claude" / "coach"
|